源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
export * from "../../../../../actions/workspaceLinkActions";
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAgentSessionEngine } from "@tutti-os/agent-activity-core";
|
||||
import { createLocalAgentGUIAgentTarget } from "../../../../../agentTargets";
|
||||
import { createTestEngineCommandPort } from "../../../../../shared/testing/createTestAgentSessionEngine";
|
||||
import { useAgentGuiConversationList } from "./useAgentGuiConversationList";
|
||||
|
||||
describe("useAgentGuiConversationList", () => {
|
||||
it("projects a child interaction as user action on its root conversation", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "claude-code" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurn: {
|
||||
agentSessionId: "root",
|
||||
origin: "user_prompt",
|
||||
phase: "waiting",
|
||||
startedAtUnixMs: 1,
|
||||
turnId: "root-turn",
|
||||
updatedAtUnixMs: 2
|
||||
},
|
||||
activeTurnId: "root-turn",
|
||||
agentSessionId: "root",
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "claude-code",
|
||||
title: "Root conversation",
|
||||
updatedAtUnixMs: 2,
|
||||
workspaceId: "workspace-1"
|
||||
},
|
||||
{
|
||||
activeTurn: {
|
||||
agentSessionId: "child",
|
||||
origin: "provider_initiated",
|
||||
phase: "waiting",
|
||||
startedAtUnixMs: 1,
|
||||
turnId: "child-turn",
|
||||
updatedAtUnixMs: 2
|
||||
},
|
||||
activeTurnId: "child-turn",
|
||||
agentSessionId: "child",
|
||||
cwd: "/workspace",
|
||||
kind: "child",
|
||||
latestTurnInteractions: [],
|
||||
parentAgentSessionId: "root",
|
||||
parentToolCallId: "tool-call-1",
|
||||
parentTurnId: "root-turn",
|
||||
pendingInteractions: [
|
||||
{
|
||||
agentSessionId: "child",
|
||||
createdAtUnixMs: 2,
|
||||
input: { questions: [{ id: "question-1" }] },
|
||||
kind: "question",
|
||||
requestId: "request-1",
|
||||
status: "pending",
|
||||
toolName: "AskUserQuestion",
|
||||
turnId: "child-turn",
|
||||
updatedAtUnixMs: 2
|
||||
}
|
||||
],
|
||||
provider: "claude-code",
|
||||
rootAgentSessionId: "root",
|
||||
rootTurnId: "root-turn",
|
||||
title: "Child task",
|
||||
updatedAtUnixMs: 2,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
needsUserAction: true,
|
||||
status: "waiting"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects a plain @ title from a matching mention-rich initial prompt", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const displayPrompt =
|
||||
"[@Task](mention://workspace-issue/issue-1?workspaceId=workspace-1) 看看";
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-1",
|
||||
agentTargetId: "local:codex",
|
||||
clientSubmitId: "submit-1",
|
||||
content: [{ type: "text", text: displayPrompt }],
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
initialDisplayPrompt: displayPrompt,
|
||||
mode: "new",
|
||||
optimisticTitle: "@Task 看看",
|
||||
railSectionKey: "project:/workspace",
|
||||
requestedAtUnixMs: 1,
|
||||
requestId: "activation-1",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
railSectionKey: "project:/workspace",
|
||||
title: "@Task 看看",
|
||||
titleLeadingMentionKind: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only conversation text for a browser-element activation", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const displayPrompt =
|
||||
"[@<div>](mention://browser-element/browser-element%3A1?tag=div&workspaceId=workspace-1) 这里说的什么";
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-1",
|
||||
agentTargetId: "local:codex",
|
||||
clientSubmitId: "submit-1",
|
||||
content: [{ type: "text", text: displayPrompt }],
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
initialDisplayPrompt: displayPrompt,
|
||||
mode: "new",
|
||||
optimisticTitle: "@<div> 这里说的什么",
|
||||
requestedAtUnixMs: 1,
|
||||
requestId: "activation-1",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "这里说的什么",
|
||||
titleLeadingMentionKind: null,
|
||||
titleFallback: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only conversation text for a canonical browser-element session", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const displayPrompt =
|
||||
"[@<div>](mention://browser-element/browser-element%3A1?tag=div&workspaceId=workspace-1) 这里说的什么";
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 1,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "@<div> 这里说的什么",
|
||||
updatedAtUnixMs: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
engine.dispatch({
|
||||
type: "message/snapshotReceived",
|
||||
messages: [
|
||||
{
|
||||
agentSessionId: "session-1",
|
||||
kind: "text",
|
||||
messageId: "message-1",
|
||||
occurredAtUnixMs: 1,
|
||||
payload: {
|
||||
displayPrompt,
|
||||
text: displayPrompt
|
||||
},
|
||||
role: "user",
|
||||
status: null,
|
||||
turnId: "turn-1",
|
||||
version: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "这里说的什么",
|
||||
titleLeadingMentionKind: null,
|
||||
titleFallback: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a historical session marker after its first message is loaded", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const displayPrompt =
|
||||
"[@Previous chat](mention://agent-session/session-previous?workspaceId=workspace-1) 看看";
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 1,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "@Previous chat 看看",
|
||||
updatedAtUnixMs: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "@Previous chat 看看",
|
||||
titleLeadingMentionKind: null
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "message/snapshotReceived",
|
||||
messages: [
|
||||
{
|
||||
agentSessionId: "session-1",
|
||||
kind: "text",
|
||||
messageId: "message-1",
|
||||
occurredAtUnixMs: 1,
|
||||
payload: { displayPrompt, text: displayPrompt },
|
||||
role: "user",
|
||||
status: null,
|
||||
turnId: "turn-1",
|
||||
version: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "@Previous chat 看看",
|
||||
titleLeadingMentionKind: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the projected list stable while an assistant message streams", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 1,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "@Task inspect",
|
||||
updatedAtUnixMs: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
const displayPrompt =
|
||||
"[@Task](mention://workspace-issue/issue-1?workspaceId=workspace-1) inspect";
|
||||
engine.dispatch({
|
||||
type: "message/snapshotReceived",
|
||||
messages: [
|
||||
{
|
||||
agentSessionId: "session-1",
|
||||
kind: "text",
|
||||
messageId: "user-1",
|
||||
occurredAtUnixMs: 1,
|
||||
payload: { displayPrompt, text: displayPrompt },
|
||||
role: "user",
|
||||
status: null,
|
||||
turnId: "turn-1",
|
||||
version: 1,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
const beforeStreaming = result.current;
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "message/snapshotReceived",
|
||||
messages: [
|
||||
{
|
||||
agentSessionId: "session-1",
|
||||
kind: "text",
|
||||
messageId: "assistant-1",
|
||||
occurredAtUnixMs: 2,
|
||||
payload: { text: "partial response" },
|
||||
role: "assistant",
|
||||
status: "streaming",
|
||||
turnId: "turn-1",
|
||||
version: 2,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current).toBe(beforeStreaming);
|
||||
});
|
||||
|
||||
it("keeps the optimistic activation title until canonical title arrives", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-1",
|
||||
agentTargetId: "local:codex",
|
||||
clientSubmitId: "submit-1",
|
||||
content: [{ type: "text", text: "test1" }],
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
mode: "new",
|
||||
requestedAtUnixMs: 1,
|
||||
requestId: "activation-1",
|
||||
optimisticTitle: "test1",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
provider: "codex",
|
||||
status: "working",
|
||||
title: "test1",
|
||||
titleFallback: null
|
||||
})
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 2,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "test1",
|
||||
updatedAtUnixMs: 2,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
status: "ready",
|
||||
title: "test1",
|
||||
titleFallback: null
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects canonical sessions and pending activation records without a list store", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-1",
|
||||
agentTargetId: "local:codex",
|
||||
clientSubmitId: "submit-1",
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
mode: "new",
|
||||
requestedAtUnixMs: 1,
|
||||
requestId: "activation-1",
|
||||
title: "Pending task",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
status: "ready",
|
||||
title: "Pending task"
|
||||
})
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
...{
|
||||
activeTurnId: null,
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: []
|
||||
},
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 2,
|
||||
cwd: "/workspace",
|
||||
provider: "codex",
|
||||
title: "Durable task",
|
||||
updatedAtUnixMs: 2,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
status: "ready",
|
||||
title: "Durable task"
|
||||
})
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurn: {
|
||||
agentSessionId: "session-1",
|
||||
origin: "user_prompt",
|
||||
phase: "running",
|
||||
startedAtUnixMs: 3,
|
||||
turnId: "turn-1",
|
||||
updatedAtUnixMs: 3
|
||||
},
|
||||
activeTurnId: "turn-1",
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 2,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "Durable task",
|
||||
updatedAtUnixMs: 3,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
expect(result.current?.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "session-1",
|
||||
status: "working",
|
||||
title: "Durable task"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps concurrent pending activations ordered and replaces only the canonicalized row", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
conversationFilter: { kind: "all" as const },
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const targets = [
|
||||
createLocalAgentGUIAgentTarget("codex"),
|
||||
createLocalAgentGUIAgentTarget("claude-code")
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query, targets)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-1",
|
||||
agentTargetId: "local:codex",
|
||||
clientSubmitId: "submit-1",
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
mode: "new",
|
||||
requestedAtUnixMs: 10,
|
||||
requestId: "activation-1",
|
||||
title: "First",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
engine.dispatch({
|
||||
type: "activation/requested",
|
||||
agentSessionId: "session-2",
|
||||
agentTargetId: "local:claude-code",
|
||||
clientSubmitId: "submit-2",
|
||||
cwd: "/workspace",
|
||||
expiresAtUnixMs: 100,
|
||||
mode: "new",
|
||||
requestedAtUnixMs: 20,
|
||||
requestId: "activation-2",
|
||||
title: "Second",
|
||||
workspaceId: "workspace-1"
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current?.conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
provider: conversation.provider,
|
||||
source: conversation.projectionSource
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: "session-2",
|
||||
provider: "claude-code",
|
||||
source: "pending_activation"
|
||||
},
|
||||
{
|
||||
id: "session-1",
|
||||
provider: "codex",
|
||||
source: "pending_activation"
|
||||
}
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "session-1",
|
||||
createdAtUnixMs: 30,
|
||||
cwd: "/workspace",
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "First durable",
|
||||
updatedAtUnixMs: 30,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current?.conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
source: conversation.projectionSource,
|
||||
title: conversation.title
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: "session-1", source: undefined, title: "First durable" },
|
||||
{
|
||||
id: "session-2",
|
||||
source: "pending_activation",
|
||||
title: "Second"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders canonical sessions by latest turn start instead of update timestamps", () => {
|
||||
const engine = createAgentSessionEngine({
|
||||
clock: { nowUnixMs: () => 1 },
|
||||
commandPort: createTestEngineCommandPort({ execute: async () => ({}) }),
|
||||
identity: { origin: "test", workspaceId: "workspace-1" },
|
||||
scheduler: { schedule: () => ({ cancel() {} }) }
|
||||
});
|
||||
const query = {
|
||||
conversationFilter: { kind: "all" as const },
|
||||
workspaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
provider: "codex" as const,
|
||||
sessionOrigin: "test"
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentGuiConversationList(engine, query)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
engine.dispatch({
|
||||
type: "session/snapshotReceived",
|
||||
sessions: [
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "older-start-newer-update",
|
||||
createdAtUnixMs: 1_000,
|
||||
cwd: "/workspace",
|
||||
latestTurn: {
|
||||
agentSessionId: "older-start-newer-update",
|
||||
origin: "user_prompt",
|
||||
phase: "running",
|
||||
startedAtUnixMs: 2_000,
|
||||
turnId: "turn-older",
|
||||
updatedAtUnixMs: 9_000
|
||||
},
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "Older turn",
|
||||
updatedAtUnixMs: 9_000,
|
||||
workspaceId: "workspace-1"
|
||||
},
|
||||
{
|
||||
activeTurnId: null,
|
||||
agentSessionId: "newer-start-older-update",
|
||||
createdAtUnixMs: 500,
|
||||
cwd: "/workspace",
|
||||
latestTurn: {
|
||||
agentSessionId: "newer-start-older-update",
|
||||
origin: "user_prompt",
|
||||
phase: "running",
|
||||
startedAtUnixMs: 3_000,
|
||||
turnId: "turn-newer",
|
||||
updatedAtUnixMs: 4_000
|
||||
},
|
||||
latestTurnInteractions: [],
|
||||
pendingInteractions: [],
|
||||
provider: "codex",
|
||||
title: "Newer turn",
|
||||
updatedAtUnixMs: 4_000,
|
||||
workspaceId: "workspace-1"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current?.conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
sortTimeUnixMs: conversation.sortTimeUnixMs
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: "newer-start-older-update", sortTimeUnixMs: 3_000 },
|
||||
{ id: "older-start-newer-update", sortTimeUnixMs: 2_000 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
isPendingActivationViable,
|
||||
selectPendingActivations,
|
||||
selectRootAgentSessionIdsWithPendingInteractions,
|
||||
selectWorkspaceAgentConsumerSessions,
|
||||
selectWorkspaceReconcileState,
|
||||
type AgentSessionEngine,
|
||||
type PendingActivationIntentRecord
|
||||
} from "@tutti-os/agent-activity-core";
|
||||
import { useEngineSelector } from "../../../../../shared/engine/useEngineSelector";
|
||||
import {
|
||||
matchesAgentGUIConversationSummaryFilter,
|
||||
normalizeAgentGUIConversationFilter,
|
||||
type AgentGUIConversationFilter
|
||||
} from "../../../../../agent-gui/agentGuiNode/model/agentGuiConversationFilter";
|
||||
import type { AgentGUIProvider } from "../../../../../types";
|
||||
import type { AgentGUIAgentTarget } from "../../../../../types";
|
||||
import type { AgentGUIConversationSummary } from "../../../../../agent-gui/agentGuiNode/model/agentGuiConversationModel";
|
||||
import {
|
||||
isAgentGUIProviderUnresolved,
|
||||
resolveAgentGUIConversationBrowserFreeTitle,
|
||||
resolveAgentGUIConversationTitleDisplayPrompt,
|
||||
resolveAgentGUIConversationTitleLeadingMentionKind,
|
||||
resolveAgentGUIConversationTitle,
|
||||
resolveAgentGUIProviderIdentity
|
||||
} from "../../../../../shared/agentConversationTitleProjection.ts";
|
||||
import { createAgentGUIConversationRailTitlePromptSelector } from "../../../../../shared/agentConversationRailTitlePromptSelector.ts";
|
||||
import { projectCanonicalAgentGUIConversationSummaries } from "../../../../../shared/agentGUIConversationSummaryProjection.ts";
|
||||
import { selectRootAgentSessionIdsAwaitingPlanImplementation } from "../../../../../shared/agentConversation/planImplementationAwaiting";
|
||||
|
||||
export interface AgentGUIConversationListQuery {
|
||||
conversationFilter?: AgentGUIConversationFilter | null;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
provider: AgentGUIProvider;
|
||||
sessionOrigin: string;
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_GUI_AGENT_TARGETS: readonly AgentGUIAgentTarget[] = [];
|
||||
|
||||
export function useAgentGuiConversationList(
|
||||
engine: AgentSessionEngine,
|
||||
query: AgentGUIConversationListQuery | null,
|
||||
agentTargets: readonly AgentGUIAgentTarget[] = EMPTY_AGENT_GUI_AGENT_TARGETS
|
||||
) {
|
||||
const workspaceReconcile = useEngineSelector(
|
||||
engine,
|
||||
selectWorkspaceReconcileState
|
||||
);
|
||||
const sessions = useEngineSelector(
|
||||
engine,
|
||||
selectWorkspaceAgentConsumerSessions,
|
||||
consumerSessionsEqual
|
||||
);
|
||||
const rootAgentSessionIdsWithPendingInteractions = useEngineSelector(
|
||||
engine,
|
||||
selectRootAgentSessionIdsWithPendingInteractions,
|
||||
stringArraysEqual
|
||||
);
|
||||
const rootAgentSessionIdsAwaitingPlanImplementation = useEngineSelector(
|
||||
engine,
|
||||
selectRootAgentSessionIdsAwaitingPlanImplementation,
|
||||
stringArraysEqual
|
||||
);
|
||||
const pendingActivations = useEngineSelector(
|
||||
engine,
|
||||
selectPendingActivations,
|
||||
pendingActivationsEqual
|
||||
);
|
||||
const selectRailTitlePrompts = useMemo(
|
||||
() => createAgentGUIConversationRailTitlePromptSelector(),
|
||||
[engine]
|
||||
);
|
||||
const firstUserDisplayPromptsBySessionId = useEngineSelector(
|
||||
engine,
|
||||
selectRailTitlePrompts,
|
||||
Object.is
|
||||
);
|
||||
return useMemo(() => {
|
||||
if (!query) return null;
|
||||
const rootSessionIdsAwaitingUserAction = new Set([
|
||||
...rootAgentSessionIdsWithPendingInteractions,
|
||||
...rootAgentSessionIdsAwaitingPlanImplementation
|
||||
]);
|
||||
const canonicalIds = new Set(
|
||||
sessions.map((item) => item.session.agentSessionId)
|
||||
);
|
||||
const latestNewActivationBySessionId = new Map(
|
||||
pendingActivations
|
||||
.filter(
|
||||
(activation) =>
|
||||
activation.mode === "new" && isPendingActivationViable(activation)
|
||||
)
|
||||
.map((activation) => [activation.agentSessionId, activation] as const)
|
||||
);
|
||||
const pendingConversations = pendingActivations
|
||||
.filter(
|
||||
(activation) =>
|
||||
activation.mode === "new" &&
|
||||
isPendingActivationViable(activation) &&
|
||||
!canonicalIds.has(activation.agentSessionId)
|
||||
)
|
||||
.map((activation): AgentGUIConversationSummary => {
|
||||
const target = agentTargets.find(
|
||||
(candidate) => candidate.agentTargetId === activation.agentTargetId
|
||||
);
|
||||
const provider = resolveAgentGUIProviderIdentity({
|
||||
sessionProvider: target?.provider ?? query.provider
|
||||
});
|
||||
const { title: canonicalTitle } = resolveAgentGUIConversationTitle(
|
||||
activation.optimisticTitle ?? activation.title ?? ""
|
||||
);
|
||||
const titleDisplayPrompt =
|
||||
resolveAgentGUIConversationTitleDisplayPrompt({
|
||||
activation,
|
||||
allowEmptyTitle: true,
|
||||
title: canonicalTitle
|
||||
});
|
||||
const { title, titleFallback } = resolveAgentGUIConversationTitle(
|
||||
resolveAgentGUIConversationBrowserFreeTitle({
|
||||
activation,
|
||||
allowEmptyTitle: true,
|
||||
title: canonicalTitle
|
||||
})
|
||||
);
|
||||
const titleLeadingMentionKind =
|
||||
resolveAgentGUIConversationTitleLeadingMentionKind(
|
||||
titleDisplayPrompt
|
||||
);
|
||||
return {
|
||||
agentTargetId: activation.agentTargetId,
|
||||
cwd: activation.cwd,
|
||||
id: activation.agentSessionId,
|
||||
provider,
|
||||
sortTimeUnixMs: activation.requestedAtUnixMs,
|
||||
status: activation.initialTurnExpected ? "working" : "ready",
|
||||
projectionSource: "pending_activation",
|
||||
railSectionKey: activation.railSectionKey,
|
||||
title,
|
||||
titleLeadingMentionKind,
|
||||
titleFallback,
|
||||
updatedAtUnixMs: activation.requestedAtUnixMs,
|
||||
userId: query.userId
|
||||
};
|
||||
});
|
||||
const conversations = [
|
||||
...projectCanonicalAgentGUIConversationSummaries(
|
||||
sessions.filter(
|
||||
(item) => item.session.workspaceId === query.workspaceId
|
||||
),
|
||||
firstUserDisplayPromptsBySessionId,
|
||||
rootSessionIdsAwaitingUserAction
|
||||
).map((conversation): AgentGUIConversationSummary => {
|
||||
const canonicalUpdatedAtUnixMs = conversation.updatedAtUnixMs;
|
||||
const activation = latestNewActivationBySessionId.get(conversation.id);
|
||||
const activationIsNewer =
|
||||
activation !== undefined &&
|
||||
activation.requestedAtUnixMs > canonicalUpdatedAtUnixMs;
|
||||
const optimisticTitle = activation?.optimisticTitle?.trim() ?? "";
|
||||
const shouldUseOptimisticTitle =
|
||||
conversation.title.trim().length === 0 && optimisticTitle.length > 0;
|
||||
const projectedTitle = shouldUseOptimisticTitle
|
||||
? optimisticTitle
|
||||
: activationIsNewer && activation?.title
|
||||
? activation.title
|
||||
: conversation.title;
|
||||
const { title: projectedCanonicalTitle } =
|
||||
resolveAgentGUIConversationTitle(projectedTitle);
|
||||
const titleDisplayPrompt =
|
||||
resolveAgentGUIConversationTitleDisplayPrompt({
|
||||
activation,
|
||||
allowEmptyTitle: shouldUseOptimisticTitle,
|
||||
title: projectedCanonicalTitle
|
||||
});
|
||||
const { title, titleFallback } = resolveAgentGUIConversationTitle(
|
||||
resolveAgentGUIConversationBrowserFreeTitle({
|
||||
activation,
|
||||
allowEmptyTitle: shouldUseOptimisticTitle,
|
||||
title: projectedCanonicalTitle
|
||||
})
|
||||
);
|
||||
const titleLeadingMentionKind =
|
||||
resolveAgentGUIConversationTitleLeadingMentionKind(
|
||||
titleDisplayPrompt
|
||||
) ?? conversation.titleLeadingMentionKind;
|
||||
return {
|
||||
...conversation,
|
||||
sortTimeUnixMs: activationIsNewer
|
||||
? activation.requestedAtUnixMs
|
||||
: conversation.sortTimeUnixMs,
|
||||
title,
|
||||
titleLeadingMentionKind,
|
||||
titleFallback,
|
||||
updatedAtUnixMs: activationIsNewer
|
||||
? activation.requestedAtUnixMs
|
||||
: canonicalUpdatedAtUnixMs
|
||||
};
|
||||
}),
|
||||
...pendingConversations
|
||||
]
|
||||
.filter((conversation) => {
|
||||
if (query.conversationFilter) {
|
||||
return matchesAgentGUIConversationSummaryFilter(
|
||||
conversation,
|
||||
normalizeAgentGUIConversationFilter(query.conversationFilter)
|
||||
);
|
||||
}
|
||||
return (
|
||||
conversation.provider === query.provider ||
|
||||
isAgentGUIProviderUnresolved(conversation.provider)
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.sortTimeUnixMs ?? right.updatedAtUnixMs) -
|
||||
(left.sortTimeUnixMs ?? left.updatedAtUnixMs) ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
return {
|
||||
conversations,
|
||||
error: workspaceReconcile.errorMessage,
|
||||
initialized:
|
||||
workspaceReconcile.status === "ready" ||
|
||||
workspaceReconcile.status === "failed" ||
|
||||
workspaceReconcile.status === "unknown",
|
||||
isLoading:
|
||||
workspaceReconcile.status === "idle" ||
|
||||
workspaceReconcile.status === "loading",
|
||||
query,
|
||||
queryKey: [
|
||||
query.workspaceId,
|
||||
query.userId,
|
||||
query.provider,
|
||||
query.sessionOrigin
|
||||
].join("::")
|
||||
};
|
||||
}, [
|
||||
agentTargets,
|
||||
firstUserDisplayPromptsBySessionId,
|
||||
pendingActivations,
|
||||
query,
|
||||
rootAgentSessionIdsAwaitingPlanImplementation,
|
||||
rootAgentSessionIdsWithPendingInteractions,
|
||||
sessions,
|
||||
workspaceReconcile
|
||||
]);
|
||||
}
|
||||
|
||||
function pendingActivationsEqual(
|
||||
left: readonly PendingActivationIntentRecord[],
|
||||
right: readonly PendingActivationIntentRecord[]
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((item, index) => item === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function consumerSessionsEqual(
|
||||
left: ReturnType<typeof selectWorkspaceAgentConsumerSessions>,
|
||||
right: ReturnType<typeof selectWorkspaceAgentConsumerSessions>
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((item, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
other !== undefined &&
|
||||
item.session === other.session &&
|
||||
item.activeTurn === other.activeTurn &&
|
||||
item.latestTurn === other.latestTurn &&
|
||||
item.pendingInteractions.length === other.pendingInteractions.length &&
|
||||
item.pendingInteractions.every(
|
||||
(interaction, interactionIndex) =>
|
||||
interaction === other.pendingInteractions[interactionIndex]
|
||||
) &&
|
||||
item.displayStatus === other.displayStatus
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function stringArraysEqual(
|
||||
left: readonly string[],
|
||||
right: readonly string[]
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((item, index) => item === right[index])
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentActivityMessage } from "@tutti-os/agent-activity-core";
|
||||
import { useAgentSessionControllerState } from "./useAgentSessionControllerState";
|
||||
|
||||
const ACTIVE_REF = {
|
||||
agentSessionId: "session-1",
|
||||
origin: "test",
|
||||
workspaceId: "workspace-1"
|
||||
};
|
||||
|
||||
function message(version: number): AgentActivityMessage {
|
||||
return {
|
||||
agentSessionId: "session-1",
|
||||
kind: "text",
|
||||
messageId: `message-${version}`,
|
||||
occurredAtUnixMs: version,
|
||||
payload: {},
|
||||
role: "assistant",
|
||||
turnId: "turn-1",
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
describe("useAgentSessionControllerState", () => {
|
||||
it("does not infer older history from a streamed message version", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ version }) =>
|
||||
useAgentSessionControllerState(ACTIVE_REF, [message(version)]),
|
||||
{ initialProps: { version: 1 } }
|
||||
);
|
||||
|
||||
expect(result.current.activeSessionView).toMatchObject({
|
||||
hasOlderMessages: false,
|
||||
oldestLoadedVersion: 1
|
||||
});
|
||||
|
||||
rerender({ version: 446 });
|
||||
|
||||
expect(result.current.activeSessionView).toMatchObject({
|
||||
hasOlderMessages: false,
|
||||
oldestLoadedVersion: 446
|
||||
});
|
||||
});
|
||||
|
||||
it("reads terminal older-page coverage from the canonical Engine window", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ hasOlderMessages, messages, oldestLoadedVersion }) =>
|
||||
useAgentSessionControllerState(ACTIVE_REF, messages, {
|
||||
hasOlderMessages,
|
||||
oldestLoadedVersion
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
hasOlderMessages: true,
|
||||
messages: [message(446)],
|
||||
oldestLoadedVersion: 446
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.current.activeSessionView?.hasOlderMessages).toBe(true);
|
||||
|
||||
rerender({
|
||||
hasOlderMessages: false,
|
||||
messages: [message(1), message(446)],
|
||||
oldestLoadedVersion: 1
|
||||
});
|
||||
|
||||
expect(result.current.activeSessionView).toMatchObject({
|
||||
hasOlderMessages: false,
|
||||
oldestLoadedVersion: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from "react";
|
||||
import type {
|
||||
AgentActivityMessage,
|
||||
AgentActivitySessionMessageWindow
|
||||
} from "@tutti-os/agent-activity-core";
|
||||
import { type AgentSessionViewRef } from "./useAgentSessionPagingState";
|
||||
import { useAgentSessionPagingState } from "./useAgentSessionPagingState";
|
||||
|
||||
export function useAgentSessionControllerState(
|
||||
activeRef: AgentSessionViewRef,
|
||||
canonicalMessages: readonly AgentActivityMessage[] = [],
|
||||
canonicalWindow: AgentActivitySessionMessageWindow | null = null
|
||||
) {
|
||||
const paging = useAgentSessionPagingState();
|
||||
const getAgentSessionView = useCallback(
|
||||
(ref: AgentSessionViewRef) => {
|
||||
const pagingEntry = paging.get(ref);
|
||||
return pagingEntry
|
||||
? {
|
||||
...pagingEntry,
|
||||
hasOlderMessages: false,
|
||||
oldestLoadedVersion: null
|
||||
}
|
||||
: null;
|
||||
},
|
||||
[paging.get]
|
||||
);
|
||||
const deleteAgentSessionView = useCallback(
|
||||
(ref: AgentSessionViewRef) => paging.remove(ref),
|
||||
[paging.remove]
|
||||
);
|
||||
const setAgentSessionViewMessagesLoading = useCallback(
|
||||
(ref: AgentSessionViewRef, value: boolean) =>
|
||||
paging.flag("isLoadingMessages", ref, value),
|
||||
[paging.flag]
|
||||
);
|
||||
const setAgentSessionViewOlderMessagesLoading = useCallback(
|
||||
(ref: AgentSessionViewRef, value: boolean) =>
|
||||
paging.flag("isLoadingOlderMessages", ref, value),
|
||||
[paging.flag]
|
||||
);
|
||||
void paging.entries;
|
||||
const storedActiveView = getAgentSessionView(activeRef);
|
||||
const canonicalOldestVersion =
|
||||
canonicalWindow?.oldestLoadedVersion ?? oldestVersion(canonicalMessages);
|
||||
const activeSessionView =
|
||||
storedActiveView === null && canonicalOldestVersion === null
|
||||
? null
|
||||
: {
|
||||
...(storedActiveView ?? {
|
||||
error: null,
|
||||
isLoadingMessages: false,
|
||||
isLoadingOlderMessages: false
|
||||
}),
|
||||
hasOlderMessages: canonicalWindow?.hasOlderMessages ?? false,
|
||||
oldestLoadedVersion: canonicalOldestVersion
|
||||
};
|
||||
return {
|
||||
activeSessionView,
|
||||
deleteAgentSessionView,
|
||||
getAgentSessionView,
|
||||
setAgentSessionViewError: paging.setError,
|
||||
setAgentSessionViewMessagesLoading,
|
||||
setAgentSessionViewOlderMessagesLoading
|
||||
};
|
||||
}
|
||||
|
||||
function oldestVersion(messages: readonly AgentActivityMessage[]) {
|
||||
const versions = messages.map((item) => item.version).filter(Number.isFinite);
|
||||
return versions.length > 0 ? Math.min(...versions) : null;
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export interface AgentSessionViewRef {
|
||||
workspaceId: string | null | undefined;
|
||||
agentSessionId: string | null | undefined;
|
||||
origin?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSessionPagingEntry {
|
||||
error: string | null;
|
||||
isLoadingMessages: boolean;
|
||||
isLoadingOlderMessages: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ENTRY: AgentSessionPagingEntry = {
|
||||
error: null,
|
||||
isLoadingMessages: false,
|
||||
isLoadingOlderMessages: false
|
||||
};
|
||||
|
||||
export function useAgentSessionPagingState() {
|
||||
const [entries, setEntries] = useState<
|
||||
Record<string, AgentSessionPagingEntry>
|
||||
>({});
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const update = useCallback(
|
||||
(
|
||||
ref: AgentSessionViewRef,
|
||||
updater: (entry: AgentSessionPagingEntry) => AgentSessionPagingEntry
|
||||
) => {
|
||||
const key = sessionKey(ref);
|
||||
if (!key) return;
|
||||
setEntries((current) => {
|
||||
const previous = current[key] ?? EMPTY_ENTRY;
|
||||
const next = updater(previous);
|
||||
return next === previous ? current : { ...current, [key]: next };
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
const get = useCallback((ref: AgentSessionViewRef) => {
|
||||
const key = sessionKey(ref);
|
||||
return key ? (entriesRef.current[key] ?? null) : null;
|
||||
}, []);
|
||||
const remove = useCallback((ref: AgentSessionViewRef) => {
|
||||
const key = sessionKey(ref);
|
||||
if (!key) return;
|
||||
setEntries((current) => {
|
||||
if (!current[key]) return current;
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const flag = useCallback(
|
||||
(
|
||||
field: "isLoadingMessages" | "isLoadingOlderMessages",
|
||||
ref: AgentSessionViewRef,
|
||||
value: boolean
|
||||
) => update(ref, (entry) => ({ ...entry, [field]: value })),
|
||||
[update]
|
||||
);
|
||||
const setError = useCallback(
|
||||
(ref: AgentSessionViewRef, error: string | null) =>
|
||||
update(ref, (entry) => ({ ...entry, error })),
|
||||
[update]
|
||||
);
|
||||
return { entries, flag, get, remove, setError };
|
||||
}
|
||||
|
||||
function sessionKey(ref: AgentSessionViewRef): string | null {
|
||||
const workspaceId = ref.workspaceId?.trim() ?? "";
|
||||
const origin = ref.origin?.trim() ?? "";
|
||||
const agentSessionId = ref.agentSessionId?.trim() ?? "";
|
||||
return workspaceId && origin && agentSessionId
|
||||
? `${workspaceId}::${origin}::${agentSessionId}`
|
||||
: null;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "../../../../../../app/renderer/lib/utils";
|
||||
|
||||
export type CanvasNodeGhostIconButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type"
|
||||
> & {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* When true (default), pointer/click stopPropagation — keeps React Flow from stealing node chrome
|
||||
* interactions. Set false for Radix `DropdownMenuTrigger asChild` and other portaled triggers.
|
||||
*/
|
||||
stopsEventPropagation?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ghost icon control for canvas node chrome.
|
||||
*/
|
||||
export const CanvasNodeGhostIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
CanvasNodeGhostIconButtonProps
|
||||
>(function CanvasNodeGhostIconButton(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
onPointerDown,
|
||||
onMouseDown,
|
||||
onClick,
|
||||
stopsEventPropagation = true,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
"use memo";
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...rest}
|
||||
type="button"
|
||||
className={cn(
|
||||
"nodrag inline-flex h-7 min-h-7 w-7 min-w-7 shrink-0 items-center justify-center rounded-[4px] border border-transparent bg-transparent p-0 text-[var(--text-secondary)] transition-[background-color,color,border-color,opacity] duration-200 ease-in-out [-webkit-app-region:no-drag] hover:border-transparent hover:bg-[var(--transparency-block)] hover:text-[var(--text-primary)] active:bg-[var(--transparency-block-active)] active:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-45 focus-visible:outline-2 focus-visible:outline-[color:color-mix(in_srgb,var(--tsh-shell-accent)_80%,white)] focus-visible:outline-offset-2 [&_svg]:block [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0 [&_svg:not([data-nexight-chrome-glyph=fill]):not([data-tutti-chrome-glyph=fill])]:fill-none [&_svg:not([data-nexight-chrome-glyph=fill]):not([data-tutti-chrome-glyph=fill])]:stroke-current data-[menu-open=true]:border-transparent data-[menu-open=true]:bg-[var(--transparency-block)] data-[menu-open=true]:text-[var(--text-primary)] data-[menu-open=true]:hover:bg-[var(--transparency-block-active)]",
|
||||
className
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
if (stopsEventPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
onPointerDown?.(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (stopsEventPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
onMouseDown?.(event);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (stopsEventPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user