Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 281 additions & 139 deletions crates/agent-gateway/internal/proto/v2/gateway.pb.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/agent-gateway/internal/protocol/pbws/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
*gatewayv2.GatewayEnvelope_HistoryDelete,
*gatewayv2.GatewayEnvelope_HistoryPrefix,
*gatewayv2.GatewayEnvelope_HistoryPin,
*gatewayv2.GatewayEnvelope_HistorySetCwd,
*gatewayv2.GatewayEnvelope_HistoryShareGet,
*gatewayv2.GatewayEnvelope_HistoryShareSet,
*gatewayv2.GatewayEnvelope_HistoryWorkdirs,
Expand Down
11 changes: 11 additions & 0 deletions crates/agent-gateway/proto/v2/gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ message GatewayEnvelope {
HistoryBranchRequest history_branch = 92;
ProviderUsageRequest provider_usage = 93;
ChatFileOpenRequest chat_file_open = 94;
HistorySetCwdRequest history_set_cwd = 95;
}

// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
Expand Down Expand Up @@ -135,6 +136,7 @@ message AgentEnvelope {
ChatIngressFragment chat_ingress_fragment = 97;
ChatFileOpenResponse chat_file_open_resp = 98;
ErrorResponse error = 99;
HistorySetCwdResponse history_set_cwd_resp = 100;
}

// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
Expand Down Expand Up @@ -1280,3 +1282,12 @@ message ChatIngressAck {
REJECTED = 4;
}
}

message HistorySetCwdRequest {
string conversation_id = 1;
string cwd = 2;
}

message HistorySetCwdResponse {
ConversationSummary conversation = 1;
}
38 changes: 38 additions & 0 deletions crates/agent-gateway/test/webui/gateway-socket-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,44 @@ test("GatewayWebSocketClient sends history branch requests with the base message
resetGatewayWebSocketClient();
});

test("GatewayWebSocketClient sends history cwd update requests", async () => {
installBrowser();
const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket();
resetGatewayWebSocketClient();

const client = getGatewayWebSocketClient("token");
const updatePromise = client.setHistoryCwd("conversation-1", "/tmp/project-b");
const socket = await connectAndAuth(codec);
await waitFor(() => findAgentRequest(codec, socket, "history_set_cwd"), "history cwd frame");
const request = findAgentRequest(codec, socket, "history_set_cwd");
assert.deepEqual(request.json.agent_request.history_set_cwd, {
conversation_id: "conversation-1",
cwd: "/tmp/project-b",
});
socket.receiveBinary(
codec.encodeServerFrame({
request_id: request.requestId,
agent_response: {
history_set_cwd_resp: {
conversation: {
id: "conversation-1",
title: "Moved conversation",
cwd: "/tmp/project-b",
message_count: 6,
created_at: 1700000000100,
updated_at: 1700000000200,
},
},
},
}),
);
const updated = await updatePromise;
assert.equal(updated.id, "conversation-1");
assert.equal(updated.cwd, "/tmp/project-b");

resetGatewayWebSocketClient();
});

test("GatewayWebSocketClient reconnects before read requests when an authenticated socket goes stale", async () => {
installBrowser();
const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,27 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
void store.setPinned(id, isPinned);
});

const handleMoveToWorkspace = useStableCallback((id: string, cwd: string) => {
if (sectionsDisabled) {
return;
}
clearMutationErrors();
void store.setCwd(id, cwd);
});

const handleMoveConversationsToWorkspace = useStableCallback(
async (ids: readonly string[], cwd: string) => {
if (sectionsDisabled) {
return ids;
}
clearMutationErrors();
const results = await Promise.all(
ids.map(async (id) => ({ id, moved: await store.setCwd(id, cwd) })),
);
return results.filter((result) => !result.moved).map((result) => result.id);
},
);

const handleDeleteConversation = useStableCallback((id: string) => {
if (sectionsDisabled) {
return;
Expand Down Expand Up @@ -369,6 +390,8 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
onCommitRename={handleCommitRename}
onCancelRename={handleCancelRename}
onSetPinned={handleSetPinned}
onMoveToWorkspace={handleMoveToWorkspace}
onMoveConversationsToWorkspace={handleMoveConversationsToWorkspace}
canShareConversations={props.canShareConversations}
sharedConversationCount={props.sharedConversationCount}
onShareConversation={props.onShareConversation}
Expand Down
7 changes: 7 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.conversationPin": "置顶对话",
"chat.conversationUnpin": "取消置顶",
"chat.conversationRename": "修改标题",
"chat.conversationMoveToWorkspace": "移动到工作空间",
"chat.conversationShare": "分享",
"chat.conversationDelete": "删除对话",
"chat.conversationDeleteConfirm": "删除「{title}」?",
Expand All @@ -110,6 +111,8 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.history.renameFailed": "修改历史对话标题失败",
"chat.history.renameBlockedRunning": "后台任务仍在运行,暂时不能修改该对话标题。",
"chat.history.pinFailed": "更新历史对话置顶状态失败",
"chat.history.moveFailed": "移动历史对话工作空间失败",
"chat.history.moveBlockedRunning": "后台任务仍在运行,暂时不能移动该对话。",
"chat.history.deleteFailed": "删除历史对话失败",
"chat.history.deleteBlockedRunning": "后台任务仍在运行,暂时不能删除该对话。",
"chat.history.openFailed": "读取历史对话失败",
Expand Down Expand Up @@ -2300,6 +2303,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.conversationPin": "Pin conversation",
"chat.conversationUnpin": "Unpin",
"chat.conversationRename": "Rename",
"chat.conversationMoveToWorkspace": "Move to workspace",
"chat.conversationShare": "Share",
"chat.conversationDelete": "Delete conversation",
"chat.conversationDeleteConfirm": 'Delete "{title}"?',
Expand All @@ -2326,6 +2330,9 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.history.renameBlockedRunning":
"A background task is still running; the title cannot be changed yet.",
"chat.history.pinFailed": "Failed to update pin state",
"chat.history.moveFailed": "Failed to move conversation to workspace",
"chat.history.moveBlockedRunning":
"A background task is still running; the conversation cannot be moved yet.",
"chat.history.deleteFailed": "Failed to delete conversation",
"chat.history.deleteBlockedRunning":
"A background task is still running; the conversation cannot be deleted yet.",
Expand Down
8 changes: 8 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2527,6 +2527,13 @@ export class GatewayWebSocketClient {
});
}

async setHistoryCwd(conversationId: string, cwd: string): Promise<ConversationSummary> {
return this.request<ConversationSummary>("history.set_cwd", {
conversation_id: conversationId,
cwd,
});
}

async getHistoryShare(conversationId: string): Promise<HistoryShareStatus> {
return this.requestWithRecovery<HistoryShareStatus>("history.share.get", {
conversation_id: conversationId,
Expand Down Expand Up @@ -3816,6 +3823,7 @@ export type GatewayWebSocketClientLike = {
baseMessageRef: HistoryMessageRef,
): Promise<ConversationSummary>;
pinHistory(conversationId: string, isPinned: boolean): Promise<ConversationSummary>;
setHistoryCwd(conversationId: string, cwd: string): Promise<ConversationSummary>;
getHistoryShare(conversationId: string): Promise<HistoryShareStatus>;
setHistoryShare(
conversationId: string,
Expand Down
10 changes: 10 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
HistoryPinRequestSchema,
HistoryPrefixRequestSchema,
HistoryRenameRequestSchema,
HistorySetCwdRequestSchema,
HistoryShareGetRequestSchema,
HistoryShareSetRequestSchema,
HistoryWorkdirsRequestSchema,
Expand Down Expand Up @@ -542,6 +543,14 @@ function agentRequestPayload(type: string, body: J): GatewayEnvelope["payload"]
isPinned: bool(body.is_pinned),
}),
};
case "history.set_cwd":
return {
case: "historySetCwd",
value: create(HistorySetCwdRequestSchema, {
conversationId: trimStr(body.conversation_id),
cwd: trimStr(body.cwd),
}),
};
case "history.share.get":
return {
case: "historyShareGet",
Expand Down Expand Up @@ -1010,6 +1019,7 @@ function decodeAgentResponse(envelope: AgentEnvelope, options: { agentOnline: bo
case "historyRenameResp":
case "historyBranchResp":
case "historyPinResp":
case "historySetCwdResp":
if (!payload.value.conversation) {
frameError("unexpected agent response");
}
Expand Down

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ type WebSidebarApi = {
listHistoryWorkdirs(): Promise<HistoryWorkdirsResponse>;
renameHistory(conversationId: string, title: string): Promise<ConversationSummary>;
pinHistory(conversationId: string, isPinned: boolean): Promise<ConversationSummary>;
setHistoryCwd(conversationId: string, cwd: string): Promise<ConversationSummary>;
deleteHistory(conversationId: string): Promise<void>;
subscribeHistory(listener: (event: GatewayHistoryEvent) => void): () => void;
subscribeConnection(listener: (connected: boolean) => void): () => void;
Expand Down Expand Up @@ -187,6 +188,10 @@ export function createWebSidebarBackend(deps: WebSidebarBackendDeps): SidebarBac
return normalizeGatewayConversationSummary(await api.pinHistory(id, isPinned));
},

async setConversationCwd(id, cwd) {
return normalizeGatewayConversationSummary(await api.setHistoryCwd(id, cwd));
},

async deleteConversation(id) {
await api.deleteHistory(id);
},
Expand Down Expand Up @@ -329,6 +334,7 @@ export function createIdleSidebarBackend(): SidebarBackend {
listWorkdirs: () => Promise.resolve([]),
renameConversation: notReady,
setConversationPinned: notReady,
setConversationCwd: notReady,
deleteConversation: notReady,
subscribeEvents: () => () => {},
getProtectedConversationIds: () => [],
Expand Down
57 changes: 56 additions & 1 deletion crates/agent-gateway/web/test/sidebar-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,22 @@ function createFakeBackend() {
listError: null,
workdirs: [],
workdirsError: null,
calls: { list: [], workdirs: 0, rename: [], pin: [], delete: [], subscribes: 0, unsubscribes: 0 },
calls: {
list: [],
workdirs: 0,
rename: [],
pin: [],
move: [],
delete: [],
subscribes: 0,
unsubscribes: 0,
},
listeners: new Set(),
connectionListeners: new Set(),
protectedIds: [],
listImpl: null,
renameImpl: null,
moveImpl: null,
deleteImpl: null,
};

Expand Down Expand Up @@ -81,6 +91,13 @@ function createFakeBackend() {
state.calls.pin.push({ id, isPinned });
return conversation(id, { isPinned, pinnedAt: isPinned ? 500 : null });
},
setConversationCwd: async (id, cwd) => {
state.calls.move.push({ id, cwd });
if (state.moveImpl) {
return state.moveImpl(id, cwd);
}
return conversation(id, { cwd });
},
deleteConversation: async (id) => {
state.calls.delete.push(id);
if (state.deleteImpl) {
Expand Down Expand Up @@ -277,6 +294,44 @@ test("rename is blocked for a running conversation without a backend call", asyn
store.stop();
});

test("move leaves the current scope optimistically, rolls back on failure, and blocks running rows", async () => {
const fake = createFakeBackend();
fake.state.pages.set("cwd:/tmp/a", [
conversation("one", { cwd: "/tmp/a", updatedAt: 20 }),
conversation("two", { cwd: "/tmp/a", updatedAt: 10 }),
]);
const store = createSidebarStore(fake.backend);
store.setScope(SCOPE_A);
store.start();
await tick();

let resolveMove;
fake.state.moveImpl = (id, cwd) =>
new Promise((resolve) => {
resolveMove = () => resolve(conversation(id, { cwd }));
});
const moving = store.setCwd("one", "/tmp/b");
assert.deepEqual(
store.getSnapshot().conversations.map((item) => item.id),
["two"],
);
assert.equal(store.getSnapshot().mutations.get("one"), "move");
resolveMove();
assert.equal(await moving, true);
assert.deepEqual(fake.state.calls.move, [{ id: "one", cwd: "/tmp/b" }]);

fake.state.moveImpl = () => Promise.reject(new Error("move failed"));
assert.equal(await store.setCwd("two", "/tmp/b"), false);
assert.equal(store.getSnapshot().conversations[0].cwd, "/tmp/a");
assert.equal(store.getSnapshot().mutationErrors.get("two"), "moveFailed");

store.applyRunningPatch({ conversationId: "two", running: true, workdir: "/tmp/a" });
assert.equal(await store.setCwd("two", "/tmp/b"), false);
assert.equal(store.getSnapshot().mutationErrors.get("two"), "moveBlockedRunning");
assert.equal(fake.state.calls.move.length, 2);
store.stop();
});

test("idle tombstone rejects stale running snapshots", () => {
const fake = createFakeBackend();
const store = createSidebarStore(fake.backend, { now: () => 999 });
Expand Down
10 changes: 9 additions & 1 deletion crates/agent-gateway/web/test/web-sidebar-backend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function createFakeApi() {
historyListeners: new Set(),
connectionListeners: new Set(),
statusListeners: new Set(),
calls: { list: [], workdirs: 0, rename: [], pin: [], delete: [] },
calls: { list: [], workdirs: 0, rename: [], pin: [], move: [], delete: [] },
};
const api = {
listHistory: async (page, pageSize, filter) => {
Expand All @@ -62,6 +62,10 @@ function createFakeApi() {
state.calls.pin.push({ id, isPinned });
return summary(id, { is_pinned: isPinned, pinned_at: isPinned ? SECONDS : 0 });
},
setHistoryCwd: async (id, cwd) => {
state.calls.move.push({ id, cwd });
return summary(id, { cwd });
},
deleteHistory: async (id) => {
state.calls.delete.push(id);
},
Expand Down Expand Up @@ -339,6 +343,10 @@ test("mutations delegate to the gateway api and normalize returned summaries", a
assert.equal(pinned.isPinned, true);
assert.equal(pinned.pinnedAt, MILLIS);

const moved = await backend.setConversationCwd("c1", "/tmp/b");
assert.deepEqual(state.calls.move, [{ id: "c1", cwd: "/tmp/b" }]);
assert.equal(moved.cwd, "/tmp/b");

await backend.deleteConversation("c1");
assert.deepEqual(state.calls.delete, ["c1"]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,31 @@ pub async fn chat_history_set_pinned(
Ok(summary)
}

pub(crate) async fn chat_history_set_cwd_inner(
id: String,
cwd: String,
) -> Result<ChatHistorySummary, String> {
tauri::async_runtime::spawn_blocking(move || {
let conn = open_db()?;
set_chat_history_cwd_sync(&conn, &id, &cwd)
})
.await
.map_err(|e| format!("chat_history_set_cwd join 失败:{e}"))?
}

#[tauri::command]
pub async fn chat_history_set_cwd(
id: String,
cwd: String,
gateway_controller: tauri::State<'_, Arc<GatewayController>>,
) -> Result<ChatHistorySummary, String> {
let summary = chat_history_set_cwd_inner(id, cwd).await?;
gateway_controller
.publish_history_sync(build_history_sync_upsert(&summary))
.await;
Ok(summary)
}

pub(crate) async fn chat_history_set_model_inner(
id: String,
selected_model_json: String,
Expand Down
Loading