From 3c6496ddf5578036a8287515a0dbac47a832454f Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 00:51:24 +0800 Subject: [PATCH 01/10] feat(project-tools): add workspace code editor --- .../internal/proto/v1/gateway.pb.go | 502 ++-- .../internal/server/websocket_fs_handlers.go | 62 + .../internal/server/websocket_payload_test.go | 15 + .../internal/server/websocket_routes.go | 1 + .../internal/server/websocket_routes_test.go | 1 + crates/agent-gateway/proto/v1/gateway.proto | 16 + crates/agent-gateway/web/package.json | 1 + crates/agent-gateway/web/pnpm-lock.yaml | 25 + crates/agent-gateway/web/src/App.tsx | 2237 ++++++++--------- .../web/src/components/icons.tsx | 22 +- .../project-tools/GitReviewPanel.tsx | 23 +- .../project-tools/ProjectFileTreePanel.tsx | 62 +- .../project-tools/ProjectToolsPanel.tsx | 112 +- .../WorkspaceCodeEditorOverlay.tsx | 1075 ++++++++ crates/agent-gateway/web/src/i18n/config.ts | 360 ++- crates/agent-gateway/web/src/index.css | 16 + .../web/src/lib/gatewaySocket.ts | 127 +- .../web/src/lib/gatewaySocket.worker.ts | 23 +- crates/agent-gateway/web/src/lib/monacoNls.ts | 49 + .../agent-gateway/web/src/shims/tauriCore.ts | 28 +- crates/agent-gateway/web/src/styles.css | 18 +- crates/agent-gateway/web/src/vite-env.d.ts | 2 + crates/agent-gui/package.json | 1 + crates/agent-gui/pnpm-lock.yaml | 25 + crates/agent-gui/src-tauri/src/commands/fs.rs | 176 ++ crates/agent-gui/src-tauri/src/lib.rs | 1 + .../src-tauri/src/services/gateway.rs | 15 + .../src-tauri/src/services/gateway_bridge.rs | 22 +- crates/agent-gui/src/components/icons.tsx | 10 + .../project-tools/GitReviewPanel.tsx | 23 +- .../project-tools/ProjectFileTreePanel.tsx | 62 +- .../project-tools/ProjectToolsPanel.tsx | 45 +- .../WorkspaceCodeEditorOverlay.tsx | 1077 ++++++++ crates/agent-gui/src/i18n/config.ts | 91 +- crates/agent-gui/src/index.css | 16 + crates/agent-gui/src/lib/monacoNls.ts | 49 + crates/agent-gui/src/pages/ChatPage.tsx | 622 +++-- crates/agent-gui/src/vite-env.d.ts | 2 + 38 files changed, 5108 insertions(+), 1906 deletions(-) create mode 100644 crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx create mode 100644 crates/agent-gateway/web/src/lib/monacoNls.ts create mode 100644 crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx create mode 100644 crates/agent-gui/src/lib/monacoNls.ts diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index afad0b3fa..eff8d9a6d 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -246,6 +246,7 @@ type GatewayEnvelope struct { // *GatewayEnvelope_FsRename // *GatewayEnvelope_FsDelete // *GatewayEnvelope_GitRequest + // *GatewayEnvelope_FsReadEditableText Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -617,6 +618,15 @@ func (x *GatewayEnvelope) GetGitRequest() *GitRequest { return nil } +func (x *GatewayEnvelope) GetFsReadEditableText() *FsReadEditableTextRequest { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_FsReadEditableText); ok { + return x.FsReadEditableText + } + } + return nil +} + type isGatewayEnvelope_Payload interface { isGatewayEnvelope_Payload() } @@ -761,6 +771,10 @@ type GatewayEnvelope_GitRequest struct { GitRequest *GitRequest `protobuf:"bytes,61,opt,name=git_request,json=gitRequest,proto3,oneof"` } +type GatewayEnvelope_FsReadEditableText struct { + FsReadEditableText *FsReadEditableTextRequest `protobuf:"bytes,62,opt,name=fs_read_editable_text,json=fsReadEditableText,proto3,oneof"` +} + func (*GatewayEnvelope_ChatRequest) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_CancelChat) isGatewayEnvelope_Payload() {} @@ -831,6 +845,8 @@ func (*GatewayEnvelope_FsDelete) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_GitRequest) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_FsReadEditableText) isGatewayEnvelope_Payload() {} + type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -874,6 +890,7 @@ type AgentEnvelope struct { // *AgentEnvelope_FsRenameResp // *AgentEnvelope_FsDeleteResp // *AgentEnvelope_GitResponse + // *AgentEnvelope_FsReadEditableTextResp // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1264,6 +1281,15 @@ func (x *AgentEnvelope) GetGitResponse() *GitResponse { return nil } +func (x *AgentEnvelope) GetFsReadEditableTextResp() *FsReadEditableTextResponse { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsReadEditableTextResp); ok { + return x.FsReadEditableTextResp + } + } + return nil +} + func (x *AgentEnvelope) GetError() *ErrorResponse { if x != nil { if x, ok := x.Payload.(*AgentEnvelope_Error); ok { @@ -1425,6 +1451,10 @@ type AgentEnvelope_GitResponse struct { GitResponse *GitResponse `protobuf:"bytes,64,opt,name=git_response,json=gitResponse,proto3,oneof"` } +type AgentEnvelope_FsReadEditableTextResp struct { + FsReadEditableTextResp *FsReadEditableTextResponse `protobuf:"bytes,65,opt,name=fs_read_editable_text_resp,json=fsReadEditableTextResp,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1503,6 +1533,8 @@ func (*AgentEnvelope_FsDeleteResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_GitResponse) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_FsReadEditableTextResp) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -5893,6 +5925,142 @@ func (x *FsListResponse) GetEntries() []*FsListEntry { return nil } +type FsReadEditableTextRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsReadEditableTextRequest) Reset() { + *x = FsReadEditableTextRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsReadEditableTextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsReadEditableTextRequest) ProtoMessage() {} + +func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[79] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. +func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} +} + +func (x *FsReadEditableTextRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *FsReadEditableTextRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FsReadEditableTextResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + MtimeMs uint64 `protobuf:"varint,3,opt,name=mtime_ms,json=mtimeMs,proto3" json:"mtime_ms,omitempty"` + ContentHash string `protobuf:"bytes,4,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` + SizeBytes uint64 `protobuf:"varint,5,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + TotalLines uint64 `protobuf:"varint,6,opt,name=total_lines,json=totalLines,proto3" json:"total_lines,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsReadEditableTextResponse) Reset() { + *x = FsReadEditableTextResponse{} + mi := &file_proto_v1_gateway_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsReadEditableTextResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsReadEditableTextResponse) ProtoMessage() {} + +func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[80] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. +func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} +} + +func (x *FsReadEditableTextResponse) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsReadEditableTextResponse) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *FsReadEditableTextResponse) GetMtimeMs() uint64 { + if x != nil { + return x.MtimeMs + } + return 0 +} + +func (x *FsReadEditableTextResponse) GetContentHash() string { + if x != nil { + return x.ContentHash + } + return "" +} + +func (x *FsReadEditableTextResponse) GetSizeBytes() uint64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *FsReadEditableTextResponse) GetTotalLines() uint64 { + if x != nil { + return x.TotalLines + } + return 0 +} + type FsWriteTextRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` @@ -5909,7 +6077,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5921,7 +6089,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5934,7 +6102,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -6008,7 +6176,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6020,7 +6188,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6033,7 +6201,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } func (x *FsWriteTextResponse) GetPath() string { @@ -6095,7 +6263,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6107,7 +6275,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6120,7 +6288,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -6147,7 +6315,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6159,7 +6327,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6172,7 +6340,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } func (x *FsCreateDirResponse) GetPath() string { @@ -6200,7 +6368,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6212,7 +6380,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6225,7 +6393,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *FsRenameRequest) GetWorkdir() string { @@ -6260,7 +6428,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6272,7 +6440,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6285,7 +6453,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *FsRenameResponse) GetFromPath() string { @@ -6319,7 +6487,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6331,7 +6499,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6344,7 +6512,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -6371,7 +6539,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6383,7 +6551,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6396,7 +6564,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *FsDeleteResponse) GetPath() string { @@ -6422,7 +6590,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6434,7 +6602,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6447,7 +6615,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *PingRequest) GetTimestamp() int64 { @@ -6466,7 +6634,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6478,7 +6646,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6491,7 +6659,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *PongResponse) GetTimestamp() int64 { @@ -6511,7 +6679,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6523,7 +6691,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6536,7 +6704,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } func (x *ErrorResponse) GetCode() int32 { @@ -6566,7 +6734,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\xb7\x17\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\x9d\x18\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6612,8 +6780,9 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\tfs_rename\x18; \x01(\v2%.liveagent.gateway.v1.FsRenameRequestH\x00R\bfsRename\x12D\n" + "\tfs_delete\x18< \x01(\v2%.liveagent.gateway.v1.FsDeleteRequestH\x00R\bfsDelete\x12C\n" + "\vgit_request\x18= \x01(\v2 .liveagent.gateway.v1.GitRequestH\x00R\n" + - "gitRequestB\t\n" + - "\apayload\"\xbe\x1b\n" + + "gitRequest\x12d\n" + + "\x15fs_read_editable_text\x18> \x01(\v2/.liveagent.gateway.v1.FsReadEditableTextRequestH\x00R\x12fsReadEditableTextB\t\n" + + "\apayload\"\xae\x1c\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6657,7 +6826,8 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x12fs_create_dir_resp\x18= \x01(\v2).liveagent.gateway.v1.FsCreateDirResponseH\x00R\x0ffsCreateDirResp\x12N\n" + "\x0efs_rename_resp\x18> \x01(\v2&.liveagent.gateway.v1.FsRenameResponseH\x00R\ffsRenameResp\x12N\n" + "\x0efs_delete_resp\x18? \x01(\v2&.liveagent.gateway.v1.FsDeleteResponseH\x00R\ffsDeleteResp\x12F\n" + - "\fgit_response\x18@ \x01(\v2!.liveagent.gateway.v1.GitResponseH\x00R\vgitResponse\x12;\n" + + "\fgit_response\x18@ \x01(\v2!.liveagent.gateway.v1.GitResponseH\x00R\vgitResponse\x12n\n" + + "\x1afs_read_editable_text_resp\x18A \x01(\v20.liveagent.gateway.v1.FsReadEditableTextResponseH\x00R\x16fsReadEditableTextResp\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + "\apayload\"|\n" + "\x11ChatSelectedModel\x12,\n" + @@ -6982,7 +7152,19 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "maxResults\x12\x14\n" + "\x05total\x18\x06 \x01(\rR\x05total\x12\x19\n" + "\bhas_more\x18\a \x01(\bR\ahasMore\x12;\n" + - "\aentries\x18\b \x03(\v2!.liveagent.gateway.v1.FsListEntryR\aentries\"\xbe\x02\n" + + "\aentries\x18\b \x03(\v2!.liveagent.gateway.v1.FsListEntryR\aentries\"I\n" + + "\x19FsReadEditableTextRequest\x12\x18\n" + + "\aworkdir\x18\x01 \x01(\tR\aworkdir\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"\xc8\x01\n" + + "\x1aFsReadEditableTextResponse\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\x12\x19\n" + + "\bmtime_ms\x18\x03 \x01(\x04R\amtimeMs\x12!\n" + + "\fcontent_hash\x18\x04 \x01(\tR\vcontentHash\x12\x1d\n" + + "\n" + + "size_bytes\x18\x05 \x01(\x04R\tsizeBytes\x12\x1f\n" + + "\vtotal_lines\x18\x06 \x01(\x04R\n" + + "totalLines\"\xbe\x02\n" + "\x12FsWriteTextRequest\x12\x18\n" + "\aworkdir\x18\x01 \x01(\tR\aworkdir\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + @@ -7045,7 +7227,7 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { } var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 90) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 92) var file_proto_v1_gateway_proto_goTypes = []any{ (ChatEvent_ChatEventType)(0), // 0: liveagent.gateway.v1.ChatEvent.ChatEventType (*AuthRequest)(nil), // 1: liveagent.gateway.v1.AuthRequest @@ -7127,125 +7309,129 @@ var file_proto_v1_gateway_proto_goTypes = []any{ (*FsListRequest)(nil), // 77: liveagent.gateway.v1.FsListRequest (*FsListEntry)(nil), // 78: liveagent.gateway.v1.FsListEntry (*FsListResponse)(nil), // 79: liveagent.gateway.v1.FsListResponse - (*FsWriteTextRequest)(nil), // 80: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 81: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 82: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 83: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 84: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 85: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 86: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 87: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 88: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 89: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 90: liveagent.gateway.v1.ErrorResponse + (*FsReadEditableTextRequest)(nil), // 80: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 81: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsWriteTextRequest)(nil), // 82: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 83: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 84: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 85: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 86: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 87: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 88: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 89: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 90: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 91: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 92: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ - 22, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest - 23, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest - 25, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 27, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 30, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 32, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 46, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 48, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest - 34, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 37, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 39, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 41, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 43, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 51, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 53, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 55, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 58, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 60, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 62, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 66, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest - 9, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 70, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 72, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 88, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest - 11, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest - 13, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 64, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 75, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest - 15, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 77, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 80, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 82, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 84, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 86, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest - 20, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 24, // 35: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 26, // 36: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 28, // 37: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 31, // 38: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 33, // 39: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 47, // 40: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 50, // 41: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 49, // 42: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse - 35, // 43: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 38, // 44: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 40, // 45: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 42, // 46: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 45, // 47: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 52, // 48: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 54, // 49: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 56, // 50: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 57, // 51: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 59, // 52: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 61, // 53: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 63, // 54: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 68, // 55: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 10, // 56: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 71, // 57: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 89, // 58: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 74, // 59: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 12, // 60: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 14, // 61: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 65, // 62: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 76, // 63: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 18, // 64: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 19, // 65: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 79, // 66: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 81, // 67: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 83, // 68: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 85, // 69: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 87, // 70: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 21, // 71: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 90, // 72: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 8, // 73: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 7, // 74: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 75: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 16, // 76: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 17, // 77: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 16, // 78: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 5, // 79: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 7, // 80: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 6, // 81: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 0, // 82: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 29, // 83: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 84: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 85: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 86: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 36, // 87: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 36, // 88: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 29, // 89: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 44, // 90: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 29, // 91: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 92: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 67, // 93: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 69, // 94: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 73, // 95: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 78, // 96: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 4, // 97: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 1, // 98: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 3, // 99: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 2, // 100: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 99, // [99:101] is the sub-list for method output_type - 97, // [97:99] is the sub-list for method input_type - 97, // [97:97] is the sub-list for extension type_name - 97, // [97:97] is the sub-list for extension extendee - 0, // [0:97] is the sub-list for field type_name + 22, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest + 23, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest + 25, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 27, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 30, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 32, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 46, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 48, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest + 34, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 37, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 39, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 41, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 43, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 51, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 53, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 55, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 58, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 60, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 62, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 66, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 9, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest + 70, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 72, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 90, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 11, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest + 13, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest + 64, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 75, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 15, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest + 77, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 82, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 84, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 86, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 88, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 20, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest + 80, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 24, // 36: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 26, // 37: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 28, // 38: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 31, // 39: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 33, // 40: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 47, // 41: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 50, // 42: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 49, // 43: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse + 35, // 44: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 38, // 45: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 40, // 46: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 42, // 47: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 45, // 48: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 52, // 49: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 54, // 50: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 56, // 51: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 57, // 52: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 59, // 53: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 61, // 54: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 63, // 55: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 68, // 56: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 10, // 57: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 71, // 58: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 91, // 59: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 74, // 60: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 12, // 61: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 14, // 62: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 65, // 63: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 76, // 64: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 18, // 65: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 19, // 66: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 79, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 83, // 68: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 85, // 69: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 87, // 70: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 89, // 71: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 21, // 72: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 81, // 73: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 92, // 74: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 8, // 75: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 7, // 76: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 16, // 77: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 16, // 78: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 17, // 79: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 16, // 80: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 5, // 81: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 7, // 82: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 6, // 83: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 0, // 84: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 29, // 85: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 29, // 86: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 29, // 87: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 29, // 88: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 36, // 89: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 36, // 90: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 29, // 91: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 44, // 92: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 29, // 93: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 29, // 94: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 67, // 95: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 69, // 96: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 73, // 97: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 78, // 98: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 4, // 99: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 1, // 100: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 3, // 101: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 2, // 102: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 101, // [101:103] is the sub-list for method output_type + 99, // [99:101] is the sub-list for method input_type + 99, // [99:99] is the sub-list for extension type_name + 99, // [99:99] is the sub-list for extension extendee + 0, // [0:99] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -7289,6 +7475,7 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_FsRename)(nil), (*GatewayEnvelope_FsDelete)(nil), (*GatewayEnvelope_GitRequest)(nil), + (*GatewayEnvelope_FsReadEditableText)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -7328,6 +7515,7 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_FsRenameResp)(nil), (*AgentEnvelope_FsDeleteResp)(nil), (*AgentEnvelope_GitResponse)(nil), + (*AgentEnvelope_FsReadEditableTextResp)(nil), (*AgentEnvelope_Error)(nil), } file_proto_v1_gateway_proto_msgTypes[38].OneofWrappers = []any{} @@ -7337,7 +7525,7 @@ func file_proto_v1_gateway_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), NumEnums: 1, - NumMessages: 90, + NumMessages: 92, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/websocket_fs_handlers.go b/crates/agent-gateway/internal/server/websocket_fs_handlers.go index ec3d8f807..a481741b1 100644 --- a/crates/agent-gateway/internal/server/websocket_fs_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_fs_handlers.go @@ -237,6 +237,57 @@ func (c *websocketConnection) handleFsList(req websocketRequest) { _ = c.writeResponse(req.ID, websocketFsListResponsePayload(resp)) } +func (c *websocketConnection) handleFsReadEditableText(req websocketRequest) { + type payload struct { + Workdir string `json:"workdir"` + Path string `json:"path"` + } + + var body payload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid fs.read_editable_text payload") + return + } + + workdir := strings.TrimSpace(body.Workdir) + path := strings.TrimSpace(body.Path) + if workdir == "" { + _ = c.writeError(req.ID, "workdir is required") + return + } + if path == "" { + _ = c.writeError(req.ID, "path is required") + return + } + + response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ + RequestId: req.ID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_FsReadEditableText{ + FsReadEditableText: &gatewayv1.FsReadEditableTextRequest{ + Workdir: workdir, + Path: path, + }, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + if errResp := response.GetError(); errResp != nil { + _ = c.writeError(req.ID, errResp.GetMessage()) + return + } + + resp := response.GetFsReadEditableTextResp() + if resp == nil { + _ = c.writeError(req.ID, "unexpected agent response") + return + } + + _ = c.writeResponse(req.ID, websocketFsReadEditableTextResponsePayload(resp)) +} + func (c *websocketConnection) handleFsWriteText(req websocketRequest) { type payload struct { Workdir string `json:"workdir"` @@ -499,6 +550,17 @@ func websocketFsListResponsePayload(resp *gatewayv1.FsListResponse) map[string]a } } +func websocketFsReadEditableTextResponsePayload(resp *gatewayv1.FsReadEditableTextResponse) map[string]any { + return map[string]any{ + "path": resp.GetPath(), + "content": resp.GetContent(), + "mtimeMs": resp.GetMtimeMs(), + "contentHash": resp.GetContentHash(), + "sizeBytes": resp.GetSizeBytes(), + "totalLines": resp.GetTotalLines(), + } +} + func websocketFsWriteTextResponsePayload(resp *gatewayv1.FsWriteTextResponse) map[string]any { return map[string]any{ "path": resp.GetPath(), diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index 506ac2448..1f43a0afc 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -94,6 +94,21 @@ func TestWebsocketFsPayloadsUseFrontendFieldNames(t *testing.T) { t.Fatalf("fs.list first entry = %#v", entries[0]) } + readEditable := websocketFsReadEditableTextResponsePayload(&gatewayv1.FsReadEditableTextResponse{ + Path: "src/main.ts", + Content: "export {};\n", + MtimeMs: 42, + ContentHash: "hash", + SizeBytes: 11, + TotalLines: 1, + }) + if readEditable["content"] != "export {};\n" { + t.Fatalf("fs.read_editable_text content = %#v", readEditable["content"]) + } + if readEditable["sizeBytes"] != uint64(11) { + t.Fatalf("fs.read_editable_text sizeBytes = %#v, want 11", readEditable["sizeBytes"]) + } + write := websocketFsWriteTextResponsePayload(&gatewayv1.FsWriteTextResponse{ Path: "src/new.ts", Mode: "rewrite", diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 1e81bf2f7..0baf1455f 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -10,6 +10,7 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "fs.list_dirs": (*websocketConnection).handleFsListDirs, "fs.create_project_folder": (*websocketConnection).handleFsCreateProjectFolder, "fs.list": (*websocketConnection).handleFsList, + "fs.read_editable_text": (*websocketConnection).handleFsReadEditableText, "fs.write_text": (*websocketConnection).handleFsWriteText, "fs.create_dir": (*websocketConnection).handleFsCreateDir, "fs.rename": (*websocketConnection).handleFsRename, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index d8685c23a..ae89d487b 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -11,6 +11,7 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "fs.list_dirs", "fs.create_project_folder", "fs.list", + "fs.read_editable_text", "fs.write_text", "fs.create_dir", "fs.rename", diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index a95038a1c..0b55cf6b9 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -61,6 +61,7 @@ message GatewayEnvelope { FsRenameRequest fs_rename = 59; FsDeleteRequest fs_delete = 60; GitRequest git_request = 61; + FsReadEditableTextRequest fs_read_editable_text = 62; } } @@ -106,6 +107,7 @@ message AgentEnvelope { FsRenameResponse fs_rename_resp = 62; FsDeleteResponse fs_delete_resp = 63; GitResponse git_response = 64; + FsReadEditableTextResponse fs_read_editable_text_resp = 65; ErrorResponse error = 99; } } @@ -549,6 +551,20 @@ message FsListResponse { repeated FsListEntry entries = 8; } +message FsReadEditableTextRequest { + string workdir = 1; + string path = 2; +} + +message FsReadEditableTextResponse { + string path = 1; + string content = 2; + uint64 mtime_ms = 3; + string content_hash = 4; + uint64 size_bytes = 5; + uint64 total_lines = 6; +} + message FsWriteTextRequest { string workdir = 1; string path = 2; diff --git a/crates/agent-gateway/web/package.json b/crates/agent-gateway/web/package.json index 4ee6887a9..4f6f9411e 100644 --- a/crates/agent-gateway/web/package.json +++ b/crates/agent-gateway/web/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.45", + "monaco-editor": "^0.55.1", "react": "^19.2.4", "react-complex-tree": "^2.6.1", "react-dom": "^19.2.4", diff --git a/crates/agent-gateway/web/pnpm-lock.yaml b/crates/agent-gateway/web/pnpm-lock.yaml index c2c8d9784..cdb71bedc 100644 --- a/crates/agent-gateway/web/pnpm-lock.yaml +++ b/crates/agent-gateway/web/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: katex: specifier: ^0.16.45 version: 0.16.45 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 react: specifier: ^19.2.4 version: 19.2.5 @@ -1418,6 +1421,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} @@ -1726,6 +1732,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -1909,6 +1920,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3691,6 +3705,10 @@ snapshots: diff@8.0.4: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -4020,6 +4038,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@16.4.2: {} marked@17.0.6: {} @@ -4458,6 +4478,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index f882ea2fa..6eb8d2187 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -1,4 +1,14 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { + Suspense, + lazy, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type DragEvent, +} from "react"; import { flushSync } from "react-dom"; import { Ban, @@ -15,14 +25,12 @@ import { import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; -import { - mergePendingUploadedFiles, - withPastedTextDisplayMetadata, -} from "@/lib/chat/uploadedFiles"; +import { mergePendingUploadedFiles, withPastedTextDisplayMetadata } from "@/lib/chat/uploadedFiles"; import { registerLocalUploadedImagePreviews } from "@/lib/chat/uploadedImagePreview"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ProjectToolsPanel } from "@/components/project-tools/ProjectToolsPanel"; +import type { WorkspaceCodeEditorOpenRequest } from "@/components/workspace-editor/WorkspaceCodeEditorOverlay"; import { LocaleContext, t as translate } from "@/i18n"; import type { MentionComposerCommitMention, @@ -85,10 +93,7 @@ import { } from "@/lib/settings/sync"; import { toModelValue } from "@/lib/providers/llm"; -import { - getGatewayWebSocketClient, - resetGatewayWebSocketClient, -} from "./lib/gatewaySocket"; +import { getGatewayWebSocketClient, resetGatewayWebSocketClient } from "./lib/gatewaySocket"; import { createGatewayGitClient } from "./lib/git/gatewayGitClient"; import { createGatewayTerminalClient } from "./lib/terminal/gatewayTerminalClient"; import { @@ -137,6 +142,11 @@ import { createLiveConversationStreamStore, type LiveConversationStreamStore, } from "./lib/liveConversationStreamStore"; +import { + lockMonacoNlsLocale, + preparePreferredMonacoNlsLocale, + setPreferredMonacoNlsLocale, +} from "./lib/monacoNls"; import { applyGatewayHistoryEvent, normalizeRunningConversations, @@ -144,21 +154,14 @@ import { upsertConversationSummary, } from "./lib/historySync"; import { clearToken, loadToken, saveToken } from "./lib/storage"; -import { - loadWebSettings, - persistWebSettings, - type WebSettingsSaveState, -} from "./lib/webSettings"; +import { loadWebSettings, persistWebSettings, type WebSettingsSaveState } from "./lib/webSettings"; import { clipboardHasFileSignal, extractClipboardFiles, readClipboardFiles, } from "./lib/clipboardFiles"; import { importReadableFiles } from "./lib/uploadReadableFiles"; -import { - normalizeGatewayAccessToken, - verifyGatewayAccessToken, -} from "./lib/gatewayAuth"; +import { normalizeGatewayAccessToken, verifyGatewayAccessToken } from "./lib/gatewayAuth"; import { parseHistoryShareToken } from "./lib/historyShare"; import { GatewayTranscript } from "./components/GatewayTranscript"; import { HistoryShareModal } from "./components/chat/HistoryShareModal"; @@ -204,16 +207,22 @@ type RunningConversationRuntime = { updatedAt: number; }; +const WorkspaceCodeEditorOverlay = lazy(async () => { + await preparePreferredMonacoNlsLocale(); + const module = await import("@/components/workspace-editor/WorkspaceCodeEditorOverlay"); + lockMonacoNlsLocale(); + return { + default: module.WorkspaceCodeEditorOverlay, + }; +}); + const MAX_UPLOAD_FILES = 9; function dragEventHasFiles(event: DragEvent) { return Array.from(event.dataTransfer.types).includes("Files"); } -function formatTranslation( - template: string, - values: Record, -) { +function formatTranslation(template: string, values: Record) { return Object.entries(values).reduce( (text, [key, value]) => text.replaceAll(`{${key}}`, String(value)), template, @@ -382,10 +391,7 @@ function HistorySwitchLoadingOverlay(props: { locale: AppSettings["locale"] }) { ); } -type ModelProviderSource = Pick< - CustomProvider, - "id" | "name" | "type" | "activeModels" ->; +type ModelProviderSource = Pick; function asErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message.trim()) return error.message.trim(); @@ -467,9 +473,7 @@ function buildGatewaySelectedModel( return undefined; } - const provider = providers.find( - (item) => item.id === selectedModel.customProviderId, - ); + const provider = providers.find((item) => item.id === selectedModel.customProviderId); if (!provider) { return undefined; } @@ -573,10 +577,8 @@ function hasLocalDraftConversation(params: { draftPinned, } = params; - const isDraftConversation = - conversationId === "" || isLocalDraftConversationId(conversationId); - const isDraftSelected = - selectedHistoryId === "" || selectedHistoryId === conversationId; + const isDraftConversation = conversationId === "" || isLocalDraftConversationId(conversationId); + const isDraftSelected = selectedHistoryId === "" || selectedHistoryId === conversationId; return ( isDraftConversation && @@ -601,20 +603,12 @@ function createConversationRuntimeEntry( } function historyMessageRefsEqual(a: HistoryMessageRef | undefined, b: HistoryMessageRef) { - return ( - a?.segmentIndex === b.segmentIndex && - a?.messageIndex === b.messageIndex - ); + return a?.segmentIndex === b.segmentIndex && a?.messageIndex === b.messageIndex; } -function truncateChatEntriesFromMessageRef( - entries: ChatEntry[], - messageRef: HistoryMessageRef, -) { +function truncateChatEntriesFromMessageRef(entries: ChatEntry[], messageRef: HistoryMessageRef) { const targetIndex = entries.findIndex( - (entry) => - entry.kind === "user" && - historyMessageRefsEqual(entry.messageRef, messageRef), + (entry) => entry.kind === "user" && historyMessageRefsEqual(entry.messageRef, messageRef), ); if (targetIndex < 0) { return entries; @@ -707,10 +701,7 @@ export default function App() { () => normalizeGatewayAccessToken(initialStoredTokenRef.current) !== "", ); const [authError, setAuthError] = useState(null); - const api = useMemo( - () => (token ? getGatewayWebSocketClient(token) : null), - [token], - ); + const api = useMemo(() => (token ? getGatewayWebSocketClient(token) : null), [token]); const terminalClient = useMemo(() => (api ? createGatewayTerminalClient(api) : null), [api]); const gitClient = useMemo(() => (api ? createGatewayGitClient(api) : null), [api]); const [status, setStatus] = useState(null); @@ -730,12 +721,12 @@ export default function App() { const [historyTotal, setHistoryTotal] = useState(0); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyError, setHistoryError] = useState(null); - const [localRunningConversationIds, setLocalRunningConversationIds] = useState>( - () => new Set(), - ); - const [remoteRunningConversationIds, setRemoteRunningConversationIds] = useState>( - () => new Set(), - ); + const [localRunningConversationIds, setLocalRunningConversationIds] = useState< + ReadonlySet + >(() => new Set()); + const [remoteRunningConversationIds, setRemoteRunningConversationIds] = useState< + ReadonlySet + >(() => new Set()); const [remoteRunningConversationRuntime, setRemoteRunningConversationRuntime] = useState< ReadonlyMap >(() => new Map()); @@ -757,6 +748,8 @@ export default function App() { const [settingsSection, setSettingsSection] = useState("system"); const [overlay, setOverlay] = useState("closed"); const [settings, setSettingsState] = useState(() => loadWebSettings(loadToken())); + // Monaco reads NLS globals while the lazy editor module imports monaco-editor. + setPreferredMonacoNlsLocale(settings.locale); const [settingsSyncReady, setSettingsSyncReady] = useState(() => token.trim() === ""); const [settingsSyncError, setSettingsSyncError] = useState(null); const [settingsSaveState, setSettingsSaveState] = useState({ @@ -824,6 +817,11 @@ export default function App() { const [isFileDropActive, setIsFileDropActive] = useState(false); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); + const [workspaceEditorOpenRequest, setWorkspaceEditorOpenRequest] = + useState(null); + const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0); + const workspaceEditorRequestIdRef = useRef(0); const [terminalSessions, setTerminalSessions] = useState([]); const { confirm: requestConfirmDialog, dialog: confirmDialog } = useConfirmDialog(); const terminalSessionsVersionRef = useRef(0); @@ -890,8 +888,9 @@ export default function App() { const protectedConversationRef = useRef(""); const chatStartLocksRef = useRef>(new Set()); const submitInFlightRef = useRef(false); - const pendingDraftConversationMigrationRef = - useRef(null); + const pendingDraftConversationMigrationRef = useRef( + null, + ); const sendChatRef = useRef(null); const settingsSaveSequenceRef = useRef(0); const settingsSaveChainRef = useRef>(Promise.resolve()); @@ -937,10 +936,7 @@ export default function App() { }, [historyWorkdirs]); function getVisibleComposerConversationId() { - return resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ); + return resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current); } function cacheVisibleComposerDraft(conversationId = getVisibleComposerConversationId()) { @@ -968,12 +964,7 @@ export default function App() { } const commitHistoryListState = useCallback( - ( - conversations: ConversationSummary[], - total: number, - nextPage: number, - hasMore?: boolean, - ) => { + (conversations: ConversationSummary[], total: number, nextPage: number, hasMore?: boolean) => { const scopedConversations = filterConversationSummariesForScope( conversations, historyListFilterRef.current, @@ -1185,49 +1176,51 @@ export default function App() { root.classList.toggle("dark", settings.theme === "dark"); }, [settings.theme]); - const applyLiveConversationTitle = useCallback(( - targetConversationId: string, - nextTitle: string, - options?: { - isFinal?: boolean; - }, - ) => { - const conversationIdValue = targetConversationId.trim(); - const title = nextTitle.trim(); - if (!conversationIdValue || !title) { - return; - } - - const updatedAt = Date.now(); - lockHistoryTitlePosition(conversationIdValue); - if (options?.isFinal) { - optimisticTitleConversationIdsRef.current.delete(conversationIdValue); - } - updateHistoryItems((current) => { - const existing = pickConversationSummary(current, conversationIdValue); - return upsertConversationSummary( - current, - { - id: conversationIdValue, - title, - created_at: existing?.created_at ?? updatedAt, - updated_at: existing?.updated_at ?? updatedAt, - message_count: existing?.message_count ?? 1, - }, - { preserveExistingUpdatedAt: existing !== null }, - ); - }); + const applyLiveConversationTitle = useCallback( + ( + targetConversationId: string, + nextTitle: string, + options?: { + isFinal?: boolean; + }, + ) => { + const conversationIdValue = targetConversationId.trim(); + const title = nextTitle.trim(); + if (!conversationIdValue || !title) { + return; + } - }, [lockHistoryTitlePosition, updateHistoryItems]); + const updatedAt = Date.now(); + lockHistoryTitlePosition(conversationIdValue); + if (options?.isFinal) { + optimisticTitleConversationIdsRef.current.delete(conversationIdValue); + } + updateHistoryItems((current) => { + const existing = pickConversationSummary(current, conversationIdValue); + return upsertConversationSummary( + current, + { + id: conversationIdValue, + title, + created_at: existing?.created_at ?? updatedAt, + updated_at: existing?.updated_at ?? updatedAt, + message_count: existing?.message_count ?? 1, + }, + { preserveExistingUpdatedAt: existing !== null }, + ); + }); + }, + [lockHistoryTitlePosition, updateHistoryItems], + ); - const applyChatToolStatus = useCallback(( - nextStatus: string | null | undefined, - isCompaction = false, - ) => { - const status = typeof nextStatus === "string" ? nextStatus.trim() : ""; - setChatToolStatus(status || null); - setChatToolStatusIsCompaction(Boolean(status) && isCompaction); - }, []); + const applyChatToolStatus = useCallback( + (nextStatus: string | null | undefined, isCompaction = false) => { + const status = typeof nextStatus === "string" ? nextStatus.trim() : ""; + setChatToolStatus(status || null); + setChatToolStatusIsCompaction(Boolean(status) && isCompaction); + }, + [], + ); const getConversationLiveStreamStore = useCallback((targetConversationId: string) => { const conversationIdValue = targetConversationId.trim(); @@ -1243,35 +1236,37 @@ export default function App() { return created; }, []); - const updateLiveConversationStreamMeta = useCallback(( - targetConversationId: string, - updater: (previous: LiveConversationStreamMeta) => LiveConversationStreamMeta, - ) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - const previous = - liveConversationStreamMetaRef.current[conversationIdValue] ?? { + const updateLiveConversationStreamMeta = useCallback( + ( + targetConversationId: string, + updater: (previous: LiveConversationStreamMeta) => LiveConversationStreamMeta, + ) => { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue) { + return; + } + const previous = liveConversationStreamMetaRef.current[conversationIdValue] ?? { hasStream: false, toolStatus: null, toolStatusIsCompaction: false, }; - const next = updater(previous); - if ( - previous.hasStream === next.hasStream && - previous.toolStatus === next.toolStatus && - previous.toolStatusIsCompaction === next.toolStatusIsCompaction - ) { - return; - } - const nextRecord = { - ...liveConversationStreamMetaRef.current, - [conversationIdValue]: next, - }; - liveConversationStreamMetaRef.current = nextRecord; - setLiveConversationStreamMetaState(nextRecord); - }, []); + const next = updater(previous); + if ( + previous.hasStream === next.hasStream && + previous.toolStatus === next.toolStatus && + previous.toolStatusIsCompaction === next.toolStatusIsCompaction + ) { + return; + } + const nextRecord = { + ...liveConversationStreamMetaRef.current, + [conversationIdValue]: next, + }; + liveConversationStreamMetaRef.current = nextRecord; + setLiveConversationStreamMetaState(nextRecord); + }, + [], + ); const markLiveConversationStreamActive = useCallback( (targetConversationId: string) => { @@ -1283,11 +1278,7 @@ export default function App() { ); const setLiveConversationStreamStatus = useCallback( - ( - targetConversationId: string, - nextStatus: string | null | undefined, - isCompaction = false, - ) => { + (targetConversationId: string, nextStatus: string | null | undefined, isCompaction = false) => { const status = normalizeOptionalStatus(nextStatus); updateLiveConversationStreamMeta(targetConversationId, (previous) => ({ ...previous, @@ -1347,10 +1338,7 @@ export default function App() { if (!conversationIdValue) { return; } - conversationRuntimeCacheRef.current.set( - conversationIdValue, - buildVisibleRuntimeEntry(), - ); + conversationRuntimeCacheRef.current.set(conversationIdValue, buildVisibleRuntimeEntry()); }, [buildVisibleRuntimeEntry], ); @@ -1367,22 +1355,18 @@ export default function App() { const previous = conversationIdRef.current === conversationIdValue && - ( - selectedHistoryIdRef.current === "" || - selectedHistoryIdRef.current === conversationIdValue - ) + (selectedHistoryIdRef.current === "" || + selectedHistoryIdRef.current === conversationIdValue) ? buildVisibleRuntimeEntry() - : conversationRuntimeCacheRef.current.get(conversationIdValue) ?? - createConversationRuntimeEntry(); + : (conversationRuntimeCacheRef.current.get(conversationIdValue) ?? + createConversationRuntimeEntry()); const next = createConversationRuntimeEntry(updater(previous)); conversationRuntimeCacheRef.current.set(conversationIdValue, next); if ( conversationIdRef.current === conversationIdValue && - ( - selectedHistoryIdRef.current === "" || - selectedHistoryIdRef.current === conversationIdValue - ) + (selectedHistoryIdRef.current === "" || + selectedHistoryIdRef.current === conversationIdValue) ) { chatMessagesRef.current = next.messages; chatErrorRef.current = next.error; @@ -1572,21 +1556,15 @@ export default function App() { ); const refreshVisibleConversationHistorySnapshot = useCallback( - async ( - targetConversationId: string, - currentApi = api, - options?: { allowIdle?: boolean }, - ) => { + async (targetConversationId: string, currentApi = api, options?: { allowIdle?: boolean }) => { const conversationIdValue = targetConversationId.trim(); if (!currentApi || !conversationIdValue) { return; } const isStillVisible = () => - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) === conversationIdValue; + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue; if ( !isStillVisible() || @@ -1616,11 +1594,9 @@ export default function App() { !isStillVisible() || getConversationAbortController(conversationIdValue) !== null || localRunningConversationIdsRef.current.has(conversationIdValue) || - ( - options?.allowIdle !== true && + (options?.allowIdle !== true && !remoteRunningConversationIdsRef.current.has(conversationIdValue) && - !hasRetainedConversationLiveStream(conversationIdValue) - ) + !hasRetainedConversationLiveStream(conversationIdValue)) ) { return; } @@ -1747,11 +1723,7 @@ export default function App() { const previousRuntime = conversationRuntimeCacheRef.current.get(previousId) ?? - ( - conversationIdRef.current === previousId - ? buildVisibleRuntimeEntry() - : null - ); + (conversationIdRef.current === previousId ? buildVisibleRuntimeEntry() : null); if (previousRuntime) { conversationRuntimeCacheRef.current.set(nextId, previousRuntime); } @@ -1795,10 +1767,7 @@ export default function App() { ); const migrateConversationSummary = useCallback( - ( - previousConversationId: string, - nextConversationId: string, - ) => { + (previousConversationId: string, nextConversationId: string) => { const previousId = previousConversationId.trim(); const nextId = nextConversationId.trim(); if (!previousId || !nextId || previousId === nextId) { @@ -1828,16 +1797,14 @@ export default function App() { id: nextId, title: shouldPreserveOptimisticTitle ? previousSummary.title - : (nextSummary?.title?.trim() || previousSummary.title), + : nextSummary?.title?.trim() || previousSummary.title, provider_id: nextSummary?.provider_id || previousSummary.provider_id, model: nextSummary?.model || previousSummary.model, session_id: nextSummary?.session_id || previousSummary.session_id, cwd: nextSummary?.cwd || previousSummary.cwd, is_pinned: nextSummary?.is_pinned ?? previousSummary.is_pinned, pinned_at: - "pinned_at" in (nextSummary ?? {}) - ? nextSummary?.pinned_at - : previousSummary.pinned_at, + "pinned_at" in (nextSummary ?? {}) ? nextSummary?.pinned_at : previousSummary.pinned_at, is_shared: nextSummary?.is_shared ?? previousSummary.is_shared, }; const withoutMigratedRows = current.filter( @@ -2011,34 +1978,36 @@ export default function App() { [api], ); - const applyGatewaySettings = useCallback((payload: GatewaySettingsSyncPayload) => { - setSettingsState((prev) => { - const rawNext = resolveAppWorkspaceProjects( - applyGatewaySettingsSyncPayload(prev, payload), - ); - const next = redactSettingsForWebStorage( - rawNext, - ); - if (!hasSettingsSyncChanged(prev, next)) { - return prev; - } - queueSettingsSave(next, "同步桌面端设置失败。", false); - return next; - }); - }, [queueSettingsSave]); - - const setSettings = useCallback((updater: (prev: AppSettings) => AppSettings) => { - setSettingsState((prev) => { - const rawNext = resolveAppWorkspaceProjects(normalizeSettings(updater(prev))); - const next = redactSettingsForWebStorage(rawNext); - queueSettingsSave( - rawNext, - "保存 WebUI 设置失败。", - hasSettingsSyncChanged(prev, next) || hasProviderApiKeyUpdates(rawNext), - ); - return next; - }); - }, [queueSettingsSave]); + const applyGatewaySettings = useCallback( + (payload: GatewaySettingsSyncPayload) => { + setSettingsState((prev) => { + const rawNext = resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(prev, payload)); + const next = redactSettingsForWebStorage(rawNext); + if (!hasSettingsSyncChanged(prev, next)) { + return prev; + } + queueSettingsSave(next, "同步桌面端设置失败。", false); + return next; + }); + }, + [queueSettingsSave], + ); + + const setSettings = useCallback( + (updater: (prev: AppSettings) => AppSettings) => { + setSettingsState((prev) => { + const rawNext = resolveAppWorkspaceProjects(normalizeSettings(updater(prev))); + const next = redactSettingsForWebStorage(rawNext); + queueSettingsSave( + rawNext, + "保存 WebUI 设置失败。", + hasSettingsSyncChanged(prev, next) || hasProviderApiKeyUpdates(rawNext), + ); + return next; + }); + }, + [queueSettingsSave], + ); const persistProjectConversationActivity = useCallback( (activity: ReadonlyMap) => { @@ -2073,18 +2042,21 @@ export default function App() { ); persistProjectConversationActivityRef.current = persistProjectConversationActivity; - const refreshHistoryWorkdirs = useCallback(async (currentApi = api) => { - if (!currentApi) { - setHistoryWorkdirs([]); - return; - } - try { - const response = await currentApi.listHistoryWorkdirs(); - setHistoryWorkdirs(response.workdirs); - } catch (error) { - console.warn("Failed to load chat history workdirs", error); - } - }, [api]); + const refreshHistoryWorkdirs = useCallback( + async (currentApi = api) => { + if (!currentApi) { + setHistoryWorkdirs([]); + return; + } + try { + const response = await currentApi.listHistoryWorkdirs(); + setHistoryWorkdirs(response.workdirs); + } catch (error) { + console.warn("Failed to load chat history workdirs", error); + } + }, + [api], + ); useEffect(() => { void refreshHistoryWorkdirs(api); @@ -2152,15 +2124,13 @@ export default function App() { const targetProject = workspaceProjects.find( (item) => - workspaceProjectPathKey(item.path) === normalizedPathKey || - item.id === project.id, + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, ) ?? project; setActiveWorkspaceProjectId(targetProject.id); setSettings((prev) => { const existing = prev.system.workspaceProjects.find( (item) => - workspaceProjectPathKey(item.path) === normalizedPathKey || - item.id === project.id, + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, ); const nextProject = existing ?? targetProject; const workspaceProjects = existing @@ -2178,10 +2148,8 @@ export default function App() { : nextProject.kind, updatedAt: item.updatedAt, lastConversationAt: - Math.max( - item.lastConversationAt ?? 0, - nextProject.lastConversationAt ?? 0, - ) || undefined, + Math.max(item.lastConversationAt ?? 0, nextProject.lastConversationAt ?? 0) || + undefined, } : item, ) @@ -2290,8 +2258,7 @@ export default function App() { setSettings((prev) => { const pathKey = workspaceProjectPathKey(project.path); const existing = prev.system.workspaceProjects.find( - (item) => - item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, + (item) => item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, ); const updatedProject: WorkspaceProject = { ...(existing ?? project), @@ -2339,12 +2306,7 @@ export default function App() { } setProjectRenamingId(null); setProjectRenameDraft(""); - }, [ - commitWorkspaceProjectRename, - projectRenameDraft, - projectRenamingId, - workspaceProjects, - ]); + }, [commitWorkspaceProjectRename, projectRenameDraft, projectRenamingId, workspaceProjects]); const handleCancelWorkspaceProjectRename = useCallback(() => { setProjectRenamingId(null); @@ -2358,8 +2320,7 @@ export default function App() { setSettings((prev) => { const existing = prev.system.workspaceProjects.find( - (item) => - item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, + (item) => item.id === project.id || workspaceProjectPathKey(item.path) === pathKey, ); if (!existing && !isPinned) { return prev; @@ -2458,7 +2419,8 @@ export default function App() { setSettingsSyncError(null); }); - void api.getSettings() + void api + .getSettings() .then((payload) => { if (!cancelled) { applyGatewaySettings(payload); @@ -2499,8 +2461,7 @@ export default function App() { } const current = remoteRunningConversationIdsRef.current; const hasConversation = current.has(conversationIdValue); - const existingRuntime = - remoteRunningConversationRuntimeRef.current.get(conversationIdValue); + const existingRuntime = remoteRunningConversationRuntimeRef.current.get(conversationIdValue); const runtimeWorkdir = runtime?.workdir?.trim() || existingRuntime?.workdir || ""; const runtimeUpdatedAt = typeof runtime?.updatedAt === "number" && @@ -2589,8 +2550,7 @@ export default function App() { return; } completedLiveStreamConversationAtRef.current.delete(conversationIdValue); - const timeoutId = - completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); + const timeoutId = completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); if (timeoutId !== undefined) { window.clearTimeout(timeoutId); completedLiveStreamCleanupTimersRef.current.delete(conversationIdValue); @@ -2610,8 +2570,7 @@ export default function App() { if (!conversationIdValue) { return; } - const existingTimeoutId = - completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); + const existingTimeoutId = completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); if (existingTimeoutId !== undefined) { window.clearTimeout(existingTimeoutId); } @@ -2623,21 +2582,24 @@ export default function App() { completedLiveStreamCleanupTimersRef.current.set(conversationIdValue, timeoutId); }, []); - const hasRecentlyCompletedLiveStream = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return false; - } - const completedAt = completedLiveStreamConversationAtRef.current.get(conversationIdValue); - if (typeof completedAt !== "number") { - return false; - } - if (Date.now() - completedAt > LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS) { - clearCompletedLiveStreamMarker(conversationIdValue); - return false; - } - return true; - }, [clearCompletedLiveStreamMarker]); + const hasRecentlyCompletedLiveStream = useCallback( + (targetConversationId: string) => { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue) { + return false; + } + const completedAt = completedLiveStreamConversationAtRef.current.get(conversationIdValue); + if (typeof completedAt !== "number") { + return false; + } + if (Date.now() - completedAt > LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS) { + clearCompletedLiveStreamMarker(conversationIdValue); + return false; + } + return true; + }, + [clearCompletedLiveStreamMarker], + ); const clearConversationStreamingState = useCallback( (targetConversationId: string) => { @@ -2654,11 +2616,7 @@ export default function App() { setConversationAbortController(conversationIdValue, null); setConversationRunningState(conversationIdValue, false); updateConversationRuntimeEntry(conversationIdValue, (current) => { - if ( - !current.isSending && - current.toolStatus === null && - !current.toolStatusIsCompaction - ) { + if (!current.isSending && current.toolStatus === null && !current.toolStatusIsCompaction) { return current; } return { @@ -2686,10 +2644,8 @@ export default function App() { } const isVisibleConversation = - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) === conversationIdValue; + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue; const shouldKeepBottom = isVisibleConversation && isTranscriptAtBottom(); if (!isVisibleConversation) { @@ -2793,10 +2749,8 @@ export default function App() { return; } if ( - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) !== conversationIdValue + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) !== + conversationIdValue ) { return; } @@ -2835,11 +2789,7 @@ export default function App() { const normalizedStatus = normalizeOptionalStatus(event.status); const isCompaction = normalizedStatus !== null && event.isCompaction === true; liveStore.setToolStatus(normalizedStatus, isCompaction); - setLiveConversationStreamStatus( - conversationIdValue, - normalizedStatus, - isCompaction, - ); + setLiveConversationStreamStatus(conversationIdValue, normalizedStatus, isCompaction); continue; } @@ -3059,8 +3009,7 @@ export default function App() { blockedHistoryHydrationConversationIdsRef.current.has(targetConversationId) || getConversationAbortController(targetConversationId) !== null; const hasLocalDraft = - pendingUploadedFilesRef.current.length > 0 || - (composerRef.current?.hasContent() ?? false); + pendingUploadedFilesRef.current.length > 0 || (composerRef.current?.hasContent() ?? false); if ( event.kind === "upsert" && visibleConversationId === targetConversationId && @@ -3153,10 +3102,8 @@ export default function App() { workdir: event.workdir, }); if ( - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) === targetConversationId && + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + targetConversationId && !isConversationLiveStreamAttached(targetConversationId) ) { attachVisibleConversationLiveStream(targetConversationId, api); @@ -3197,11 +3144,7 @@ export default function App() { const normalizedStatus = normalizeOptionalStatus(event.status); const isCompaction = normalizedStatus !== null && event.isCompaction === true; liveStore.setToolStatus(normalizedStatus, isCompaction); - setLiveConversationStreamStatus( - targetConversationId, - normalizedStatus, - isCompaction, - ); + setLiveConversationStreamStatus(targetConversationId, normalizedStatus, isCompaction); return; } @@ -3283,8 +3226,7 @@ export default function App() { const previousSelectedHistoryId = selectedHistoryIdRef.current.trim(); const isChangingSelectedHistory = previousSelectedHistoryId !== conversationIdValue; const isSwitchingVisibleConversation = - currentVisibleConversationId !== "" && - currentVisibleConversationId !== conversationIdValue; + currentVisibleConversationId !== "" && currentVisibleConversationId !== conversationIdValue; if (options?.scrollToBottom) { pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; } @@ -3309,9 +3251,7 @@ export default function App() { try { const detail = await currentApi.getHistory( conversationIdValue, - options?.fullHistory - ? undefined - : { maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES }, + options?.fullHistory ? undefined : { maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES }, ); if ( historyLoadSequenceRef.current !== loadSequence || @@ -3340,13 +3280,11 @@ export default function App() { } const currentRuntime = conversationIdRef.current.trim() === detail.conversation_id && - ( - selectedHistoryIdRef.current.trim() === "" || - selectedHistoryIdRef.current.trim() === detail.conversation_id - ) + (selectedHistoryIdRef.current.trim() === "" || + selectedHistoryIdRef.current.trim() === detail.conversation_id) ? buildVisibleRuntimeEntry() - : conversationRuntimeCacheRef.current.get(detail.conversation_id) ?? - createConversationRuntimeEntry(); + : (conversationRuntimeCacheRef.current.get(detail.conversation_id) ?? + createConversationRuntimeEntry()); const nextMessages = mergeHistorySnapshotEntries(currentRuntime.messages, entries, { isFullSnapshot: detail.has_more === false, }); @@ -3438,11 +3376,7 @@ export default function App() { const requestScopeKey = historyScopeKeyRef.current; const requestFilter = historyListFilterRef.current; try { - const response = await currentApi.listHistory( - 1, - HISTORY_LIST_PAGE_SIZE, - requestFilter, - ); + const response = await currentApi.listHistory(1, HISTORY_LIST_PAGE_SIZE, requestFilter); if (requestScopeKey !== historyScopeKeyRef.current) { return; } @@ -3493,11 +3427,7 @@ export default function App() { const nextPage = silent ? Math.max(nextHistoryPageRef.current, refreshedNextPage) : refreshedNextPage; - commitHistoryListState( - conversations, - response.total_count, - nextPage, - ); + commitHistoryListState(conversations, response.total_count, nextPage); const adoptedPendingDraftConversationId = options?.adoptPendingDraftConversation === true @@ -3512,9 +3442,7 @@ export default function App() { // conversation, only the temporary hydration block survives migration; // release it here so this same reload/select cycle can hydrate the // recovered history snapshot immediately. - blockedHistoryHydrationConversationIdsRef.current.delete( - adoptedPendingDraftConversationId, - ); + blockedHistoryHydrationConversationIdsRef.current.delete(adoptedPendingDraftConversationId); } if (options?.skipSelectionSync) { @@ -3525,23 +3453,20 @@ export default function App() { const currentSelectedHistoryId = selectedHistoryIdRef.current; const currentChatMessages = chatMessagesRef.current; const currentSelectedHistory = selectedHistoryRef.current; - const requestedPreferredConversationId = - options?.preferredConversationId?.trim() ?? ""; + const requestedPreferredConversationId = options?.preferredConversationId?.trim() ?? ""; const requestedConversationId = requestedPreferredConversationId !== "" && !isLocalDraftConversationId(requestedPreferredConversationId) ? requestedPreferredConversationId : adoptedPendingDraftConversationId || requestedPreferredConversationId; const protectedConversationId = protectedConversationRef.current.trim(); - const isProtectedDraftConversation = - protectedConversationId === PROTECTED_DRAFT_CONVERSATION; + const isProtectedDraftConversation = protectedConversationId === PROTECTED_DRAFT_CONVERSATION; const hadCurrentConversationInHistory = pickConversationSummary(historyItemsRef.current, currentConversationId) !== null; const currentSummary = pickConversationSummary(conversations, currentConversationId); const protectedConversationSummary = - protectedConversationId && - !isProtectedDraftConversation + protectedConversationId && !isProtectedDraftConversation ? pickConversationSummary(conversations, protectedConversationId) : null; @@ -3565,10 +3490,8 @@ export default function App() { protectedConversationId && protectedConversationSummary === null && (requestedConversationId === "" || requestedConversationId === protectedConversationId) && - ( - currentConversationId === protectedConversationId || - currentSelectedHistoryId === protectedConversationId - ) + (currentConversationId === protectedConversationId || + currentSelectedHistoryId === protectedConversationId) ) { return; } @@ -3617,7 +3540,7 @@ export default function App() { ? currentConversationId : currentConversationId && currentChatMessages.length > 0 ? "" - : conversations[0]?.id ?? ""); + : (conversations[0]?.id ?? "")); if (!preferredConversationId) { if (!currentConversationId) { @@ -3633,13 +3556,10 @@ export default function App() { const shouldSyncChat = options?.hydrateSelection === true || - ( - (currentConversationId === "" || isLocalDraftConversationId(currentConversationId)) && - currentChatMessages.length === 0 - ); + ((currentConversationId === "" || isLocalDraftConversationId(currentConversationId)) && + currentChatMessages.length === 0); const shouldHydrateSelection = - shouldSyncChat || - currentSelectedHistory?.conversation_id !== preferredConversationId; + shouldSyncChat || currentSelectedHistory?.conversation_id !== preferredConversationId; if (shouldHydrateSelection) { await selectHistory(preferredConversationId, currentApi, { @@ -3688,11 +3608,7 @@ export default function App() { const requestFilter = historyListFilterRef.current; try { const pageNumber = nextHistoryPageRef.current; - const response = await api.listHistory( - pageNumber, - HISTORY_LIST_PAGE_SIZE, - requestFilter, - ); + const response = await api.listHistory(pageNumber, HISTORY_LIST_PAGE_SIZE, requestFilter); if (requestScopeKey !== historyScopeKeyRef.current) { return; } @@ -3716,13 +3632,8 @@ export default function App() { retainConversationIds, }, ); - const nextPage = - response.conversations.length === 0 ? pageNumber : pageNumber + 1; - commitHistoryListState( - conversations, - response.total_count, - nextPage, - ); + const nextPage = response.conversations.length === 0 ? pageNumber : pageNumber + 1; + commitHistoryListState(conversations, response.total_count, nextPage); setHistoryError(null); } catch (error) { if (requestScopeKey !== historyScopeKeyRef.current) { @@ -3756,10 +3667,7 @@ export default function App() { const effectiveConversationId = !isLocalDraftConversationId(targetId) && targetId !== "" ? targetId - : ( - visibleConversationId !== "" && - !isLocalDraftConversationId(visibleConversationId) - ) + : visibleConversationId !== "" && !isLocalDraftConversationId(visibleConversationId) ? visibleConversationId : targetId; @@ -3854,13 +3762,11 @@ export default function App() { const runtimeConversationWorkdir = conversationRuntimeCacheRef.current.get(activeConversationId)?.workdir?.trim() || ""; const effectiveWorkdir = isAgentMode - ? ( - options?.workdir?.trim() || - persistedConversationWorkdir || - runtimeConversationWorkdir || - activeWorkspaceProjectPath || - settings.system.workdir.trim() - ) + ? options?.workdir?.trim() || + persistedConversationWorkdir || + runtimeConversationWorkdir || + activeWorkspaceProjectPath || + settings.system.workdir.trim() : ""; const optimisticDraftTitle = buildOptimisticConversationTitle(message); draftConversationPinnedRef.current = false; @@ -3956,14 +3862,18 @@ export default function App() { if (existing) { return current; } - return upsertConversationSummary(current, { - id: activeConversationId, - title: optimisticDraftTitle, - created_at: startedAt, - updated_at: startedAt, - message_count: 1, - cwd: effectiveWorkdir || undefined, - }, { preserveExistingTitle: true }); + return upsertConversationSummary( + current, + { + id: activeConversationId, + title: optimisticDraftTitle, + created_at: startedAt, + updated_at: startedAt, + message_count: 1, + cwd: effectiveWorkdir || undefined, + }, + { preserveExistingTitle: true }, + ); }); } } @@ -3979,11 +3889,7 @@ export default function App() { normalizedStatus, isCompaction, ); - setLiveConversationStreamStatus( - activeConversationId, - normalizedStatus, - isCompaction, - ); + setLiveConversationStreamStatus(activeConversationId, normalizedStatus, isCompaction); updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, toolStatus: normalizedStatus, @@ -4035,8 +3941,7 @@ export default function App() { } blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); if ( - pendingDraftConversationMigrationRef.current?.draftConversationId === - activeConversationId + pendingDraftConversationMigrationRef.current?.draftConversationId === activeConversationId ) { pendingDraftConversationMigrationRef.current = null; } @@ -4051,17 +3956,14 @@ export default function App() { sendChatRef.current = sendChat; async function cancelChat(targetConversationId?: string) { - const activeConversationId = - targetConversationId?.trim() || conversationIdRef.current.trim(); + const activeConversationId = targetConversationId?.trim() || conversationIdRef.current.trim(); if (!activeConversationId) { return; } const controller = getConversationAbortController(activeConversationId); const isVisibleConversation = - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) === activeConversationId; + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + activeConversationId; const shouldKeepBottom = isVisibleConversation && isTranscriptAtBottom(); const cancelRequest = !controller && @@ -4142,9 +4044,7 @@ export default function App() { const currentProject = workspaceProjects.find((item) => item.id === current); if ( current === project.id || - (pathKey && - currentProject && - workspaceProjectPathKey(currentProject.path) === pathKey) + (pathKey && currentProject && workspaceProjectPathKey(currentProject.path) === pathKey) ) { return DEFAULT_WORKSPACE_PROJECT_ID; } @@ -4166,8 +4066,7 @@ export default function App() { { ...prev.system, workspaceProjects: prev.system.workspaceProjects.filter( - (item) => - item.id !== project.id && workspaceProjectPathKey(item.path) !== pathKey, + (item) => item.id !== project.id && workspaceProjectPathKey(item.path) !== pathKey, ), hiddenWorkspaceProjectPaths: nextHidden, missingWorkspaceProjectPaths: prev.system.missingWorkspaceProjectPaths.filter( @@ -4276,7 +4175,9 @@ export default function App() { }; if (terminalClient && settings.remote.enableWebTerminal && pathKey) { terminalSessionsToClose = await terminalClient.list(pathKey); - const runningTerminalCount = terminalSessionsToClose.filter((session) => session.running).length; + const runningTerminalCount = terminalSessionsToClose.filter( + (session) => session.running, + ).length; if (runningTerminalCount > 0) { const confirmed = await requestConfirmDialog({ title: translate("chat.workspaceRemoveConfirm", settings.locale).replace( @@ -4322,8 +4223,9 @@ export default function App() { const visibleRuntimeWorkdir = conversationRuntimeCacheRef.current.get(visibleConversationId)?.workdir?.trim() || ""; const visiblePersistedWorkdir = - historyItemsRef.current.find((item) => item.id === visibleConversationId)?.cwd?.trim() || - ""; + historyItemsRef.current + .find((item) => item.id === visibleConversationId) + ?.cwd?.trim() || ""; const visibleWorkdir = visiblePersistedWorkdir || visibleRuntimeWorkdir || @@ -4435,10 +4337,7 @@ export default function App() { }); const currentConversationId = conversationIdRef.current.trim(); - if ( - currentConversationId && - currentConversationId !== targetConversationId - ) { + if (currentConversationId && currentConversationId !== targetConversationId) { cacheVisibleConversationRuntime(currentConversationId); } @@ -4451,10 +4350,7 @@ export default function App() { } if ( cachedRuntime && - ( - cachedRuntime.isSending || - localRunningConversationIdsRef.current.has(targetConversationId) - ) + (cachedRuntime.isSending || localRunningConversationIdsRef.current.has(targetConversationId)) ) { invalidateHistoryLoad(); markVisibleConversationRevision(); @@ -4599,9 +4495,7 @@ export default function App() { } const setSharedHistoryItemsState = useCallback((items: ChatHistorySummary[]) => { - const nextItems = sortHistoryItems( - items.map((item) => ({ ...item, isShared: true })), - ); + const nextItems = sortHistoryItems(items.map((item) => ({ ...item, isShared: true }))); sharedHistoryItemsRef.current = nextItems; setSharedHistoryItems(nextItems); }, []); @@ -4673,9 +4567,7 @@ export default function App() { ), ); if (!isShared) { - setSharedHistoryItemsState( - sharedHistoryItemsRef.current.filter((item) => item.id !== id), - ); + setSharedHistoryItemsState(sharedHistoryItemsRef.current.filter((item) => item.id !== id)); return; } @@ -4793,200 +4685,201 @@ export default function App() { }); } - const resolveUserMessageRef = useCallback(async ( - userOrdinal: number, - text: string, - uploadedFiles: PendingUploadedFile[], - ) => { - const activeConversationId = conversationIdRef.current.trim(); - if ( - !api || - !activeConversationId || - isLocalDraftConversationId(activeConversationId) || - hasDetachedHistorySelection(selectedHistoryIdRef.current, activeConversationId) - ) { - return null; - } - - const detail = await api.getHistory(activeConversationId); - const entries = await parseHistoryMessagesJsonAsync(detail.messages_json); - return findUserMessageRefByOrdinal(entries, userOrdinal, text, uploadedFiles); - }, [api]); - - const handleResendFromEdit = useCallback(async ( - messageRef: HistoryMessageRef, - text: string, - uploadedFiles: PendingUploadedFile[], - ) => { - const activeConversationId = conversationIdRef.current.trim(); - if (!api || chatBusyRef.current || !activeConversationId || isLocalDraftConversationId(activeConversationId)) { - return; - } - const normalized = text.trim(); - if (!normalized && uploadedFiles.length === 0) { - return; - } + const resolveUserMessageRef = useCallback( + async (userOrdinal: number, text: string, uploadedFiles: PendingUploadedFile[]) => { + const activeConversationId = conversationIdRef.current.trim(); + if ( + !api || + !activeConversationId || + isLocalDraftConversationId(activeConversationId) || + hasDetachedHistorySelection(selectedHistoryIdRef.current, activeConversationId) + ) { + return null; + } - setHistoryError(null); - setChatError(null); - composerRef.current?.clear(); - setPendingUploadedFiles([]); - blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); - invalidateHistoryLoad(); - markVisibleConversationRevision(); + const detail = await api.getHistory(activeConversationId); + const entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + return findUserMessageRefByOrdinal(entries, userOrdinal, text, uploadedFiles); + }, + [api], + ); - try { - const currentRuntime = - conversationIdRef.current.trim() === activeConversationId && - ( - selectedHistoryIdRef.current.trim() === "" || - selectedHistoryIdRef.current.trim() === activeConversationId - ) - ? buildVisibleRuntimeEntry() - : conversationRuntimeCacheRef.current.get(activeConversationId) ?? - buildVisibleRuntimeEntry(); - const locallyTruncatedEntries = truncateChatEntriesFromMessageRef( - currentRuntime.messages, - messageRef, - ); - const canUseLocalTruncate = locallyTruncatedEntries !== currentRuntime.messages; - const detail = await api.truncateHistory(activeConversationId, messageRef, { - omitMessagesJson: canUseLocalTruncate, - }); - const entries = canUseLocalTruncate - ? locallyTruncatedEntries - : await parseHistoryMessagesJsonAsync(detail.messages_json); - const nextRuntime = createConversationRuntimeEntry({ - messages: entries, - error: null, - toolStatus: null, - toolStatusIsCompaction: false, - isSending: false, - }); + const handleResendFromEdit = useCallback( + async (messageRef: HistoryMessageRef, text: string, uploadedFiles: PendingUploadedFile[]) => { + const activeConversationId = conversationIdRef.current.trim(); + if ( + !api || + chatBusyRef.current || + !activeConversationId || + isLocalDraftConversationId(activeConversationId) + ) { + return; + } + const normalized = text.trim(); + if (!normalized && uploadedFiles.length === 0) { + return; + } - clearConversationLiveStream(activeConversationId); - setSelectedHistory(detail); - setSelectedHistoryEntries(entries); - conversationRuntimeCacheRef.current.set(activeConversationId, nextRuntime); - syncVisibleConversationRuntime(activeConversationId, nextRuntime); + setHistoryError(null); + setChatError(null); + composerRef.current?.clear(); + setPendingUploadedFiles([]); + blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); + invalidateHistoryLoad(); + markVisibleConversationRevision(); - const truncatedConversation = detail.conversation; - if (truncatedConversation) { - updateHistoryItems((current) => - upsertConversationSummary(current, truncatedConversation), + try { + const currentRuntime = + conversationIdRef.current.trim() === activeConversationId && + (selectedHistoryIdRef.current.trim() === "" || + selectedHistoryIdRef.current.trim() === activeConversationId) + ? buildVisibleRuntimeEntry() + : (conversationRuntimeCacheRef.current.get(activeConversationId) ?? + buildVisibleRuntimeEntry()); + const locallyTruncatedEntries = truncateChatEntriesFromMessageRef( + currentRuntime.messages, + messageRef, ); - } + const canUseLocalTruncate = locallyTruncatedEntries !== currentRuntime.messages; + const detail = await api.truncateHistory(activeConversationId, messageRef, { + omitMessagesJson: canUseLocalTruncate, + }); + const entries = canUseLocalTruncate + ? locallyTruncatedEntries + : await parseHistoryMessagesJsonAsync(detail.messages_json); + const nextRuntime = createConversationRuntimeEntry({ + messages: entries, + error: null, + toolStatus: null, + toolStatusIsCompaction: false, + isSending: false, + }); - const resendPromise = sendChatRef.current?.(normalized, { - conversationId: activeConversationId, - uploadedFiles, - }) ?? Promise.resolve(); - blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); - await resendPromise; - } catch (error) { - setChatError(asErrorMessage(error, "回溯历史消息失败")); - } finally { - blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); - } - }, [ - api, - buildVisibleRuntimeEntry, - clearConversationLiveStream, - invalidateHistoryLoad, - markVisibleConversationRevision, - syncVisibleConversationRuntime, - updateHistoryItems, - ]); - - const handleImportReadableFiles = useCallback(async (filesToImport: File[]) => { - if (filesToImport.length === 0) { - return; - } - if (chatBusyRef.current) { - setChatError(translate("chat.upload.busyGenerating", settings.locale)); - return; - } - if (isUploadingFilesRef.current) { - setChatError(translate("chat.upload.uploading", settings.locale)); - return; - } - if (settings.system.executionMode === "text") { - setChatError(translate("chat.upload.onlyInTools", settings.locale)); - return; - } - const workdir = displayedConversationWorkdirRef.current.trim(); - if (!workdir) { - setChatError(translate("chat.upload.requireWorkdir", settings.locale)); - return; - } - - const remainingFileSlots = Math.max( - 0, - MAX_UPLOAD_FILES - pendingUploadedFilesRef.current.length, - ); - if (remainingFileSlots === 0) { - setChatError( - formatTranslation(translate("chat.upload.maxFilesIgnored", settings.locale), { - max: MAX_UPLOAD_FILES, - count: filesToImport.length, - }), - ); - return; - } - - const importBatch = filesToImport.slice(0, remainingFileSlots); - const ignoredForLimit = filesToImport.length - importBatch.length; - setChatError(null); - isUploadingFilesRef.current = true; - setIsUploadingFiles(true); - try { - const result = await importReadableFiles(token, workdir, importBatch); - registerLocalUploadedImagePreviews({ - workspaceRoot: workdir, - uploadedFiles: result.files, - sourceFiles: importBatch, - }); - - if (result.files.length > 0) { - setPendingUploadedFiles((current) => { - const next = mergePendingUploadedFiles(current, result.files).slice( - 0, - MAX_UPLOAD_FILES, + clearConversationLiveStream(activeConversationId); + setSelectedHistory(detail); + setSelectedHistoryEntries(entries); + conversationRuntimeCacheRef.current.set(activeConversationId, nextRuntime); + syncVisibleConversationRuntime(activeConversationId, nextRuntime); + + const truncatedConversation = detail.conversation; + if (truncatedConversation) { + updateHistoryItems((current) => + upsertConversationSummary(current, truncatedConversation), ); - pendingUploadedFilesRef.current = next; - return next; - }); - composerRef.current?.focus(); + } + + const resendPromise = + sendChatRef.current?.(normalized, { + conversationId: activeConversationId, + uploadedFiles, + }) ?? Promise.resolve(); + blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); + await resendPromise; + } catch (error) { + setChatError(asErrorMessage(error, "回溯历史消息失败")); + } finally { + blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); } + }, + [ + api, + buildVisibleRuntimeEntry, + clearConversationLiveStream, + invalidateHistoryLoad, + markVisibleConversationRevision, + syncVisibleConversationRuntime, + updateHistoryItems, + ], + ); - const warnings: string[] = []; - if (result.files.length === 0 && result.skipped.length > 0) { - warnings.push(`所选文件均无法导入:\n${result.skipped.join("\n")}`); - } else if (result.skipped.length > 0) { - warnings.push(`以下文件已跳过:\n${result.skipped.join("\n")}`); + const handleImportReadableFiles = useCallback( + async (filesToImport: File[]) => { + if (filesToImport.length === 0) { + return; + } + if (chatBusyRef.current) { + setChatError(translate("chat.upload.busyGenerating", settings.locale)); + return; } - if (ignoredForLimit > 0) { - warnings.push( + if (isUploadingFilesRef.current) { + setChatError(translate("chat.upload.uploading", settings.locale)); + return; + } + if (settings.system.executionMode === "text") { + setChatError(translate("chat.upload.onlyInTools", settings.locale)); + return; + } + const workdir = displayedConversationWorkdirRef.current.trim(); + if (!workdir) { + setChatError(translate("chat.upload.requireWorkdir", settings.locale)); + return; + } + + const remainingFileSlots = Math.max( + 0, + MAX_UPLOAD_FILES - pendingUploadedFilesRef.current.length, + ); + if (remainingFileSlots === 0) { + setChatError( formatTranslation(translate("chat.upload.maxFilesIgnored", settings.locale), { max: MAX_UPLOAD_FILES, - count: ignoredForLimit, + count: filesToImport.length, }), ); + return; } - if (warnings.length > 0) { - setChatError(warnings.join("\n")); + + const importBatch = filesToImport.slice(0, remainingFileSlots); + const ignoredForLimit = filesToImport.length - importBatch.length; + setChatError(null); + isUploadingFilesRef.current = true; + setIsUploadingFiles(true); + try { + const result = await importReadableFiles(token, workdir, importBatch); + registerLocalUploadedImagePreviews({ + workspaceRoot: workdir, + uploadedFiles: result.files, + sourceFiles: importBatch, + }); + + if (result.files.length > 0) { + setPendingUploadedFiles((current) => { + const next = mergePendingUploadedFiles(current, result.files).slice( + 0, + MAX_UPLOAD_FILES, + ); + pendingUploadedFilesRef.current = next; + return next; + }); + composerRef.current?.focus(); + } + + const warnings: string[] = []; + if (result.files.length === 0 && result.skipped.length > 0) { + warnings.push(`所选文件均无法导入:\n${result.skipped.join("\n")}`); + } else if (result.skipped.length > 0) { + warnings.push(`以下文件已跳过:\n${result.skipped.join("\n")}`); + } + if (ignoredForLimit > 0) { + warnings.push( + formatTranslation(translate("chat.upload.maxFilesIgnored", settings.locale), { + max: MAX_UPLOAD_FILES, + count: ignoredForLimit, + }), + ); + } + if (warnings.length > 0) { + setChatError(warnings.join("\n")); + } + } catch (error) { + setChatError(asErrorMessage(error, "导入文件失败")); + } finally { + isUploadingFilesRef.current = false; + setIsUploadingFiles(false); } - } catch (error) { - setChatError(asErrorMessage(error, "导入文件失败")); - } finally { - isUploadingFilesRef.current = false; - setIsUploadingFiles(false); - } - }, [ - settings.locale, - settings.system.executionMode, - token, - ]); + }, + [settings.locale, settings.system.executionMode, token], + ); useEffect(() => { if ( @@ -5038,19 +4931,19 @@ export default function App() { token, ]); - const handleLoadUploadedImagePreview = useCallback(async ( - workspaceRoot: string, - absolutePath: string, - ) => { - if (!api) { - return null; - } - const result = await api.readUploadedImagePreview(workspaceRoot, absolutePath); - if (!result.data.trim()) { - return null; - } - return result; - }, [api]); + const handleLoadUploadedImagePreview = useCallback( + async (workspaceRoot: string, absolutePath: string) => { + if (!api) { + return null; + } + const result = await api.readUploadedImagePreview(workspaceRoot, absolutePath); + if (!result.data.trim()) { + return null; + } + return result; + }, + [api], + ); const handleComposerBusyChange = useCallback((_isBusy: boolean) => {}, []); @@ -5258,24 +5151,16 @@ export default function App() { providerId: currentChatProvider?.type, requestFormat: currentChatProvider?.requestFormat, }), - [ - currentChatProvider?.requestFormat, - currentChatProvider?.type, - settings.chatRuntimeControls, - ], + [currentChatProvider?.requestFormat, currentChatProvider?.type, settings.chatRuntimeControls], ); const handleChatRuntimeControlsChange = useCallback( (patch: Partial) => { setSettings((prev) => ({ ...prev, - chatRuntimeControls: updateChatRuntimeControlsForProvider( - prev.chatRuntimeControls, - patch, - { - providerId: currentChatProvider?.type, - requestFormat: currentChatProvider?.requestFormat, - }, - ), + chatRuntimeControls: updateChatRuntimeControlsForProvider(prev.chatRuntimeControls, patch, { + providerId: currentChatProvider?.type, + requestFormat: currentChatProvider?.requestFormat, + }), })); }, [currentChatProvider?.requestFormat, currentChatProvider?.type, setSettings], @@ -5292,10 +5177,7 @@ export default function App() { () => (skillsEnabled ? mergeAlwaysEnabledSkillNames(settings.skills.selected) : []), [skillsEnabled, settings.skills.selected], ); - const { - availableSkills, - skillsRootDir, - } = useChatSkills({ + const { availableSkills, skillsRootDir } = useChatSkills({ skillsEnabled, selectedSkillNames, setSettings, @@ -5311,16 +5193,14 @@ export default function App() { }, [availableSkills, selectedSkillNames, skillsEnabled]); const sidebarItems = useMemo( - () => - historyItems - .map((item) => toChatHistorySummary(item, settings.selectedModel)), + () => historyItems.map((item) => toChatHistorySummary(item, settings.selectedModel)), [historyItems, settings.selectedModel], ); const canShareHistory = Boolean( api && - settings.remote.enabled && - settings.remote.gatewayUrl.trim() && - settings.remote.token.trim(), + settings.remote.enabled && + settings.remote.gatewayUrl.trim() && + settings.remote.token.trim(), ); const sidebarRunningConversationIds = useMemo(() => { const next = new Set(remoteRunningConversationIds); @@ -5381,10 +5261,7 @@ export default function App() { projectActivityUpdatedAtOverrides, remoteRunningConversationRuntime, ]); - const displayedConversationId = resolveVisibleConversationId( - selectedHistoryId, - conversationId, - ); + const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); const currentConversationPersistedCwd = historyItems.find((item) => item.id === displayedConversationId)?.cwd?.trim() || ""; const currentConversationRuntimeWorkdir = @@ -5395,7 +5272,9 @@ export default function App() { (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); displayedConversationWorkdirRef.current = displayedConversationWorkdir; const terminalProjectPath = isAgentMode ? activeWorkspaceProjectPath.trim() : ""; - const terminalProjectPathKey = terminalProjectPath ? workspaceProjectPathKey(terminalProjectPath) : ""; + const terminalProjectPathKey = terminalProjectPath + ? workspaceProjectPathKey(terminalProjectPath) + : ""; const projectToolsDisabledMessage = !settingsSyncReady ? "Syncing desktop settings..." : !isAgentMode @@ -5411,6 +5290,23 @@ export default function App() { const gitDisabledMessage = !settings.remote.enableWebGit ? "WebUI Git is disabled in desktop Remote settings." : undefined; + const handleOpenEditableFile = useCallback( + (path: string) => { + if (!terminalProjectPath || !terminalProjectPathKey) return; + workspaceEditorRequestIdRef.current += 1; + setWorkspaceEditorOpen(true); + setWorkspaceEditorOpenRequest({ + id: workspaceEditorRequestIdRef.current, + projectPathKey: terminalProjectPathKey, + workdir: terminalProjectPath, + path, + }); + }, + [terminalProjectPath, terminalProjectPathKey], + ); + const requestWorkspaceEditorClose = useCallback(() => { + setWorkspaceEditorCloseRequestId((current) => current + 1); + }, []); const projectTerminalSessions = useMemo( () => terminalProjectPathKey @@ -5518,8 +5414,7 @@ export default function App() { } return pickConversationSummary(historyItems, displayedId); }, [displayedConversationId, historyItems]); - const activeProjectBrowserTitle = - isAgentMode ? activeWorkspaceProject?.name.trim() ?? "" : ""; + const activeProjectBrowserTitle = isAgentMode ? (activeWorkspaceProject?.name.trim() ?? "") : ""; const displayedConversationTitle = useMemo( () => resolveConversationBrowserTitle({ @@ -5546,13 +5441,8 @@ export default function App() { } return displayedConversationTitle || DEFAULT_BROWSER_TITLE; }, [activeView, displayedConversationTitle, historyShareToken, token]); - const hasDetachedSelection = hasDetachedHistorySelection( - selectedHistoryId, - conversationId, - ); - const visibleTranscriptEntries = hasDetachedSelection - ? selectedHistoryEntries - : chatMessages; + const hasDetachedSelection = hasDetachedHistorySelection(selectedHistoryId, conversationId); + const visibleTranscriptEntries = hasDetachedSelection ? selectedHistoryEntries : chatMessages; const historyDetailLoadingTitle = useMemo(() => { const selectedId = selectedHistoryId.trim(); if (!selectedId) { @@ -5562,9 +5452,7 @@ export default function App() { return item ? resolveConversationTitle(item, item.id) : ""; }, [historyItems, selectedHistoryId]); const transcriptHistoryLoading = - historyDetailLoading && - hasDetachedSelection && - selectedHistoryEntries.length === 0; + historyDetailLoading && hasDetachedSelection && selectedHistoryEntries.length === 0; const selectedHistoryHasMore = selectedHistory?.conversation_id === displayedConversationId && selectedHistory.has_more === true; @@ -5584,11 +5472,11 @@ export default function App() { const liveTranscriptStore = displayedConversationId !== "" ? getConversationLiveStreamStore(displayedConversationId) : null; const liveTranscriptMeta = - displayedConversationId !== "" ? liveConversationStreamMeta[displayedConversationId] : undefined; + displayedConversationId !== "" + ? liveConversationStreamMeta[displayedConversationId] + : undefined; const isLocallyStreamingDisplayedConversation = - chatBusy && - conversationId.trim() !== "" && - displayedConversationId === conversationId.trim(); + chatBusy && conversationId.trim() !== "" && displayedConversationId === conversationId.trim(); const isObservingRemoteLiveConversation = Boolean( !isLocallyStreamingDisplayedConversation && displayedConversationId !== "" && @@ -5611,11 +5499,7 @@ export default function App() { } } - if ( - api && - isObservingRemoteLiveConversation && - nextDisplayedConversationId !== "" - ) { + if (api && isObservingRemoteLiveConversation && nextDisplayedConversationId !== "") { attachVisibleConversationLiveStream(nextDisplayedConversationId, api); } }, [ @@ -5633,20 +5517,18 @@ export default function App() { document.title = browserTitle; }, [browserTitle]); const transcriptToolStatus = isObservingRemoteLiveConversation - ? liveTranscriptMeta?.toolStatus ?? null + ? (liveTranscriptMeta?.toolStatus ?? null) : hasDetachedSelection ? null : chatToolStatus; const transcriptToolStatusIsCompaction = isObservingRemoteLiveConversation - ? liveTranscriptMeta?.toolStatusIsCompaction ?? false + ? (liveTranscriptMeta?.toolStatusIsCompaction ?? false) : hasDetachedSelection ? false : chatToolStatusIsCompaction; - const transcriptBusy = - (!hasDetachedSelection && chatBusy) || isObservingRemoteLiveConversation; + const transcriptBusy = (!hasDetachedSelection && chatBusy) || isObservingRemoteLiveConversation; const composerIsSending = chatBusy || isObservingRemoteLiveConversation; - const transcriptError = - hasDetachedSelection || chatMessages.length === 0 ? null : chatError; + const transcriptError = hasDetachedSelection || chatMessages.length === 0 ? null : chatError; const composerCompactionBlocked = transcriptToolStatusIsCompaction; const composerInputDisabled = !status?.online || historyDetailLoading || composerCompactionBlocked; @@ -5676,10 +5558,9 @@ export default function App() { const fileDropDescription = canDropUpload ? translate("chat.upload.dropHint", settings.locale) : translate("chat.upload.dropDisabledHint", settings.locale); - const fileDropLimitHint = formatTranslation( - translate("chat.upload.dropLimit", settings.locale), - { max: MAX_UPLOAD_FILES }, - ); + const fileDropLimitHint = formatTranslation(translate("chat.upload.dropLimit", settings.locale), { + max: MAX_UPLOAD_FILES, + }); const handleFileDragEnter = useCallback((event: DragEvent) => { if (!dragEventHasFiles(event)) return; @@ -5689,13 +5570,16 @@ export default function App() { setIsFileDropActive(true); }, []); - const handleFileDragOver = useCallback((event: DragEvent) => { - if (!dragEventHasFiles(event)) return; - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = canDropUpload ? "copy" : "none"; - setIsFileDropActive(true); - }, [canDropUpload]); + const handleFileDragOver = useCallback( + (event: DragEvent) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canDropUpload ? "copy" : "none"; + setIsFileDropActive(true); + }, + [canDropUpload], + ); const handleFileDragLeave = useCallback((event: DragEvent) => { if (!dragEventHasFiles(event)) return; @@ -5707,21 +5591,24 @@ export default function App() { } }, []); - const handleFileDrop = useCallback((event: DragEvent) => { - if (!dragEventHasFiles(event)) return; - event.preventDefault(); - event.stopPropagation(); - uploadDragDepthRef.current = 0; - setIsFileDropActive(false); + const handleFileDrop = useCallback( + (event: DragEvent) => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + uploadDragDepthRef.current = 0; + setIsFileDropActive(false); - const files = Array.from(event.dataTransfer.files ?? []); - if (files.length === 0) return; - if (!canDropUpload) { - setChatError(fileDropTitle); - return; - } - void handleImportReadableFiles(files); - }, [canDropUpload, fileDropTitle, handleImportReadableFiles]); + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length === 0) return; + if (!canDropUpload) { + setChatError(fileDropTitle); + return; + } + void handleImportReadableFiles(files); + }, + [canDropUpload, fileDropTitle, handleImportReadableFiles], + ); useEffect(() => { const nextDisplayedConversationId = displayedConversationId.trim(); @@ -5743,10 +5630,7 @@ export default function App() { !targetConversationId || historyDetailLoading || displayedConversationId.trim() !== targetConversationId || - ( - visibleTranscriptEntries.length === 0 && - liveTranscriptMeta?.hasStream !== true - ) + (visibleTranscriptEntries.length === 0 && liveTranscriptMeta?.hasStream !== true) ) { return; } @@ -5772,8 +5656,7 @@ export default function App() { const currentDisplayedConversationId = displayedConversationId.trim(); const currentSelectedHistoryId = selectedHistoryId.trim(); const isTargetVisible = currentDisplayedConversationId === targetConversationId; - const isTargetSelected = - isTargetVisible || currentSelectedHistoryId === targetConversationId; + const isTargetSelected = isTargetVisible || currentSelectedHistoryId === targetConversationId; if (historyDetailLoading && isTargetSelected) { return; @@ -5869,9 +5752,7 @@ export default function App() {
-
- 正在同步桌面端设置... -
+
正在同步桌面端设置...
@@ -5894,521 +5775,540 @@ export default function App() { }} /> - { - setRenamingId(item.id); - setRenameDraft(item.title); - }} - onRenameDraftChange={setRenameDraft} - onCommitRename={() => { - if (!renamingId) { - return; - } - const conversationIdValue = renamingId; - const title = renameDraft.trim(); - setHistoryError(null); - void (async () => { - if (!title) { - setRenamingId(null); - setRenameDraft(""); +
+ { + setRenamingId(item.id); + setRenameDraft(item.title); + }} + onRenameDraftChange={setRenameDraft} + onCommitRename={() => { + if (!renamingId) { return; } - setHistoryMutating(true); - try { - const summary = await api.renameHistory(conversationIdValue, title); - optimisticTitleConversationIdsRef.current.delete(conversationIdValue); - unlockHistoryTitlePosition(conversationIdValue); - updateHistoryItems((current) => upsertConversationSummary(current, summary)); - } catch (error) { - setHistoryError(asErrorMessage(error, "修改历史对话标题失败")); - } finally { - setHistoryMutating(false); - setRenamingId(null); - setRenameDraft(""); - } - })(); - }} - onCancelRename={() => { - setRenamingId(null); - setRenameDraft(""); - }} - onSetPinned={(id, isPinned) => { - setHistoryError(null); - void (async () => { - setHistoryMutating(true); - try { - const summary = await api.pinHistory(id, isPinned); - updateHistoryItems((current) => upsertConversationSummary(current, summary)); - } catch (error) { - setHistoryError(asErrorMessage(error, "更新历史对话置顶状态失败")); - } finally { - setHistoryMutating(false); - } - })(); - }} - canShareConversations={canShareHistory} - sharedConversationCount={sharedHistoryItems.length} - onShareConversation={handleOpenShareModal} - onOpenSharedConversations={handleOpenSharedHistoryManager} - onDeleteConversation={(id) => { - setHistoryError(null); - if (sidebarRunningConversationIds.has(id)) { - setHistoryError("后台任务仍在运行,暂时不能删除该对话。"); - return; - } - void (async () => { - setHistoryMutating(true); - try { - await api.deleteHistory(id); - optimisticTitleConversationIdsRef.current.delete(id); - unlockHistoryTitlePosition(id); - updateHistoryItems((current) => current.filter((item) => item.id !== id)); - setSharedHistoryItemsState( - sharedHistoryItemsRef.current.filter((item) => item.id !== id), - ); - if ( - conversationIdRef.current === id || - selectedHistoryIdRef.current === id - ) { - startNewConversation({ - workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, - }); + const conversationIdValue = renamingId; + const title = renameDraft.trim(); + setHistoryError(null); + void (async () => { + if (!title) { + setRenamingId(null); + setRenameDraft(""); + return; } - } catch (error) { - setHistoryError(asErrorMessage(error, "删除历史对话失败")); - } finally { - setHistoryMutating(false); + setHistoryMutating(true); + try { + const summary = await api.renameHistory(conversationIdValue, title); + optimisticTitleConversationIdsRef.current.delete(conversationIdValue); + unlockHistoryTitlePosition(conversationIdValue); + updateHistoryItems((current) => upsertConversationSummary(current, summary)); + } catch (error) { + setHistoryError(asErrorMessage(error, "修改历史对话标题失败")); + } finally { + setHistoryMutating(false); + setRenamingId(null); + setRenameDraft(""); + } + })(); + }} + onCancelRename={() => { + setRenamingId(null); + setRenameDraft(""); + }} + onSetPinned={(id, isPinned) => { + setHistoryError(null); + void (async () => { + setHistoryMutating(true); + try { + const summary = await api.pinHistory(id, isPinned); + updateHistoryItems((current) => upsertConversationSummary(current, summary)); + } catch (error) { + setHistoryError(asErrorMessage(error, "更新历史对话置顶状态失败")); + } finally { + setHistoryMutating(false); + } + })(); + }} + canShareConversations={canShareHistory} + sharedConversationCount={sharedHistoryItems.length} + onShareConversation={handleOpenShareModal} + onOpenSharedConversations={handleOpenSharedHistoryManager} + onDeleteConversation={(id) => { + setHistoryError(null); + if (sidebarRunningConversationIds.has(id)) { + setHistoryError("后台任务仍在运行,暂时不能删除该对话。"); + return; } - })(); - }} - onLoadMore={loadMoreHistory} - onCloseSidebar={() => setSidebarOpen(false)} - onOpenSkillsHub={handleSidebarOpenSkillsHub} - onOpenMcpHub={handleSidebarOpenMcpHub} - /> - - {shareConversation ? ( - { + setHistoryMutating(true); + try { + await api.deleteHistory(id); + optimisticTitleConversationIdsRef.current.delete(id); + unlockHistoryTitlePosition(id); + updateHistoryItems((current) => current.filter((item) => item.id !== id)); + setSharedHistoryItemsState( + sharedHistoryItemsRef.current.filter((item) => item.id !== id), + ); + if (conversationIdRef.current === id || selectedHistoryIdRef.current === id) { + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + }); + } + } catch (error) { + setHistoryError(asErrorMessage(error, "删除历史对话失败")); + } finally { + setHistoryMutating(false); + } + })(); + }} + onLoadMore={loadMoreHistory} + onCloseSidebar={() => setSidebarOpen(false)} + onOpenSkillsHub={handleSidebarOpenSkillsHub} + onOpenMcpHub={handleSidebarOpenMcpHub} /> - ) : null} - {sharedManagerOpen ? ( - setSharedManagerOpen(false)} - /> - ) : null} + {shareConversation ? ( + + ) : null} + + {sharedManagerOpen ? ( + setSharedManagerOpen(false)} + /> + ) : null} - {projectPickerOpen ? ( - setProjectPickerOpen(false)} - onSelect={handleWorkdirPickerSelect} - /> - ) : null} + {projectPickerOpen ? ( + setProjectPickerOpen(false)} + onSelect={handleWorkdirPickerSelect} + /> + ) : null} - {confirmDialog} + {confirmDialog} -
-
- {activeView === "skills-hub" ? ( - setSidebarOpen(true)} - /> - ) : activeView === "mcp-hub" ? ( - setSidebarOpen(true)} - /> - ) : ( -
- 0} - currentModelLabel={currentModelLabel} - modelOptions={modelOptions} - selectedValue={selectedValue} - sidebarOpen={sidebarOpen} - setSettings={setSettings} - onOpenSettings={openSettings} - onToggleTheme={() => - setSettings((prev) => ({ - ...prev, - theme: prev.theme === "dark" ? "light" : "dark", - })) - } - onOpenSidebar={() => setSidebarOpen(true)} - preThemeActions={ - - {status?.online ? "Online" : "Offline"} - - } - trailingActions={ - <> - - - - - - +
+ {activeView === "skills-hub" ? ( + setSidebarOpen(true)} + /> + ) : activeView === "mcp-hub" ? ( + setSidebarOpen(true)} + /> + ) : ( +
+ 0} + currentModelLabel={currentModelLabel} + modelOptions={modelOptions} + selectedValue={selectedValue} + sidebarOpen={sidebarOpen} + setSettings={setSettings} + onOpenSettings={openSettings} + onToggleTheme={() => + setSettings((prev) => ({ + ...prev, + theme: prev.theme === "dark" ? "light" : "dark", + })) + } + onOpenSidebar={() => setSidebarOpen(true)} + preThemeActions={ + - -
{userMenuLabel}
-
- Session {status?.session_id || "N/A"} -
-
- - + } + trailingActions={ + <> + + + + + + + +
+ {userMenuLabel} +
+
+ Session {status?.session_id || "N/A"} +
+
+ + + + 退出登录 + +
+
+ + } + /> - {statusError ?
{statusError}
: null} - {settingsSyncError ?
{settingsSyncError}
: null} - {chatError && chatMessages.length === 0 && !hasDetachedSelection ? ( -
{chatError}
- ) : null} - -
-
- - 0} - onOpenSettings={openSettings} - hasMoreHistory={selectedHistoryHasMore} - isLoadingMoreHistory={loadingOlderHistory} - onLoadFullHistory={selectedHistoryHasMore ? handleLoadFullHistory : undefined} + {statusError ?
{statusError}
: null} + {settingsSyncError ? ( +
{settingsSyncError}
+ ) : null} + {chatError && chatMessages.length === 0 && !hasDetachedSelection ? ( +
{chatError}
+ ) : null} + +
+
+ + 0} + onOpenSettings={openSettings} + hasMoreHistory={selectedHistoryHasMore} + isLoadingMoreHistory={loadingOlderHistory} + onLoadFullHistory={ + selectedHistoryHasMore ? handleLoadFullHistory : undefined + } + isAgentMode={isAgentMode} + showUsage={isAgentDevExecutionMode} + usageContextWindow={currentModelContextWindow} + workspaceRoot={displayedConversationWorkdir} + gitClient={gitClient} + onLoadUploadedImagePreview={handleLoadUploadedImagePreview} + onResendFromEdit={hasDetachedSelection ? undefined : handleResendFromEdit} + onResolveUserMessageRef={ + hasDetachedSelection ? undefined : resolveUserMessageRef + } + /> + + {historySwitchOverlay ? ( + + ) : null} +
+ {showTranscriptJumpToBottom ? ( + + ) : null} + - - {historySwitchOverlay ? ( - - ) : null} -
- {showTranscriptJumpToBottom ? ( - - ) : null} - - window.dispatchEvent( - new CustomEvent("liveagent:git-changed", { - detail: { workdir: gitWorkdir }, - }), - ) - } - onSend={() => { - if ( - submitInFlightRef.current || - chatBusyRef.current || - isObservingRemoteLiveConversation || - isUploadingFiles || - isImportingPastedTextRef.current || - composerInputDisabled - ) { - return; - } - submitInFlightRef.current = true; - void (async () => { - try { - const draft = composerRef.current?.getDraft() ?? null; - let text = draft - ? ( - isAgentMode && draft.largePastes.length > 0 - ? draft.textWithoutLargePastes - : buildTextFromComposerDraft(draft) - ).trim() - : ""; - let files = pendingUploadedFiles; - - if (isAgentMode && draft && draft.largePastes.length > 0) { - setChatError(null); - isImportingPastedTextRef.current = true; - setIsUploadingFiles(true); + gitWriteEnabled={settings.remote.enableWebGit} + gitDisabledMessage={gitDisabledMessage} + onGitChanged={(gitWorkdir) => + window.dispatchEvent( + new CustomEvent("liveagent:git-changed", { + detail: { workdir: gitWorkdir }, + }), + ) + } + onSend={() => { + if ( + submitInFlightRef.current || + chatBusyRef.current || + isObservingRemoteLiveConversation || + isUploadingFiles || + isImportingPastedTextRef.current || + composerInputDisabled + ) { + return; + } + submitInFlightRef.current = true; + void (async () => { try { - const imported = await importPastedTextsAsFiles({ - token, - workdir: displayedConversationWorkdir, - pastes: draft.largePastes, + const draft = composerRef.current?.getDraft() ?? null; + let text = draft + ? (isAgentMode && draft.largePastes.length > 0 + ? draft.textWithoutLargePastes + : buildTextFromComposerDraft(draft) + ).trim() + : ""; + let files = pendingUploadedFiles; + + if (isAgentMode && draft && draft.largePastes.length > 0) { + setChatError(null); + isImportingPastedTextRef.current = true; + setIsUploadingFiles(true); + try { + const imported = await importPastedTextsAsFiles({ + token, + workdir: displayedConversationWorkdir, + pastes: draft.largePastes, + }); + text = buildTextFromComposerDraft( + draft, + imported.fileByPasteId, + ).trim(); + files = mergePendingUploadedFiles(files, imported.files); + } catch (error) { + setChatError(asErrorMessage(error, "大段粘贴内容导入失败")); + return; + } finally { + isImportingPastedTextRef.current = false; + setIsUploadingFiles(false); + } + } + + if (!text && files.length === 0) { + return; + } + composerRef.current?.clear(); + setPendingUploadedFiles([]); + void sendChat(text, { + uploadedFiles: files, + runtimeControls: chatRuntimeControlsForCurrentProvider, + }).catch(() => { + setPendingUploadedFiles((current) => + mergePendingUploadedFiles(current, files), + ); }); - text = buildTextFromComposerDraft(draft, imported.fileByPasteId).trim(); - files = mergePendingUploadedFiles(files, imported.files); - } catch (error) { - setChatError(asErrorMessage(error, "大段粘贴内容导入失败")); - return; } finally { - isImportingPastedTextRef.current = false; - setIsUploadingFiles(false); + submitInFlightRef.current = false; } - } - - if (!text && files.length === 0) { - return; - } - composerRef.current?.clear(); - setPendingUploadedFiles([]); - void sendChat(text, { - uploadedFiles: files, - runtimeControls: chatRuntimeControlsForCurrentProvider, - }).catch(() => { - setPendingUploadedFiles((current) => - mergePendingUploadedFiles(current, files), - ); - }); - } finally { - submitInFlightRef.current = false; - } - })(); - }} - onStop={() => { - void cancelChat( - isObservingRemoteLiveConversation - ? displayedConversationId - : undefined, - ); - }} - onComposerBusyChange={handleComposerBusyChange} - onChatRuntimeControlsChange={handleChatRuntimeControlsChange} - onPickReadableFiles={() => fileInputRef.current?.click()} - onPasteFiles={handleImportReadableFiles} - pendingUploadedFiles={pendingUploadedFiles} - onRemovePendingUpload={(relativePath) => { - setPendingUploadedFiles((current) => - current.filter((file) => file.relativePath !== relativePath), - ); - }} - /> - {isFileDropActive ? ( -
+
+ )} +
+ {workspaceEditorOpen ? ( + + {translate("workspaceEditor.loading", settings.locale)}
- ) : null} - - - - )} - + } + > + setWorkspaceEditorOpen(false)} + /> + + ) : null} + {terminalClient ? ( + onFileTreeOpenChange={(open) => { setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open), - ) - } + ); + if (!open) { + requestWorkspaceEditorClose(); + } + }} onFileTreeStateChange={(patch) => setSettings((prev) => updateProjectToolsFileTreeProjectState(prev, terminalProjectPathKey, patch), @@ -6487,6 +6387,7 @@ export default function App() { composerRef.current?.insertFileMention(path, kind); composerRef.current?.focus(); }} + onOpenEditableFile={handleOpenEditableFile} onInsertCommitMention={(commit) => { composerRef.current?.insertCommitMention(commit); composerRef.current?.focus(); diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index e99828886..1313b4763 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -19,6 +19,7 @@ import ChevronUpSource from "~icons/lucide/chevron-up"; import CircleSource from "~icons/lucide/circle"; import Clock3Source from "~icons/lucide/clock-3"; import CloudSource from "~icons/lucide/cloud"; +import ClipboardPasteSource from "~icons/lucide/clipboard-paste"; import CopySource from "~icons/lucide/copy"; import CpuSource from "~icons/lucide/cpu"; import DownloadSource from "~icons/lucide/download"; @@ -66,8 +67,12 @@ import PlaySource from "~icons/lucide/play"; import PlugSource from "~icons/lucide/plug"; import PlusSource from "~icons/lucide/plus"; import RadioSource from "~icons/lucide/radio"; +import Redo2Source from "~icons/lucide/redo-2"; import RefreshCwSource from "~icons/lucide/refresh-cw"; +import ReplaceSource from "~icons/lucide/replace"; +import SaveSource from "~icons/lucide/save"; import ScrollTextSource from "~icons/lucide/scroll-text"; +import ScissorsSource from "~icons/lucide/scissors"; import SearchSource from "~icons/lucide/search"; import SendSource from "~icons/lucide/send"; import ServerSource from "~icons/lucide/server"; @@ -82,7 +87,9 @@ import SunSource from "~icons/lucide/sun"; import TagSource from "~icons/lucide/tag"; import TerminalSource from "~icons/lucide/terminal"; import TimerSource from "~icons/lucide/timer"; +import TextSelectSource from "~icons/lucide/text-select"; import Trash2Source from "~icons/lucide/trash-2"; +import Undo2Source from "~icons/lucide/undo-2"; import UploadSource from "~icons/lucide/upload"; import UserSource from "~icons/lucide/user"; import WifiSource from "~icons/lucide/wifi"; @@ -105,7 +112,13 @@ type IconProps = SVGProps & { export type IconComponent = ComponentType; function createIcon(Source: IconSource): IconComponent { - return function Icon({ absoluteStrokeWidth: _absoluteStrokeWidth, height, size, width, ...props }) { + return function Icon({ + absoluteStrokeWidth: _absoluteStrokeWidth, + height, + size, + width, + ...props + }) { const nextProps: IconProps = { ...props }; if (size !== undefined) { nextProps.width = width ?? size; @@ -135,6 +148,7 @@ export const ChevronDown = createIcon(ChevronDownSource); export const ChevronRight = createIcon(ChevronRightSource); export const ChevronUp = createIcon(ChevronUpSource); export const Circle = createIcon(CircleSource); +export const ClipboardPaste = createIcon(ClipboardPasteSource); export const Clock3 = createIcon(Clock3Source); export const Cloud = createIcon(CloudSource); export const Copy = createIcon(CopySource); @@ -184,8 +198,12 @@ export const Play = createIcon(PlaySource); export const Plug = createIcon(PlugSource); export const Plus = createIcon(PlusSource); export const Radio = createIcon(RadioSource); +export const Redo2 = createIcon(Redo2Source); export const RefreshCw = createIcon(RefreshCwSource); +export const Replace = createIcon(ReplaceSource); +export const Save = createIcon(SaveSource); export const ScrollText = createIcon(ScrollTextSource); +export const Scissors = createIcon(ScissorsSource); export const Search = createIcon(SearchSource); export const Send = createIcon(SendSource); export const Server = createIcon(ServerSource); @@ -200,7 +218,9 @@ export const Sun = createIcon(SunSource); export const Tag = createIcon(TagSource); export const Terminal = createIcon(TerminalSource); export const Timer = createIcon(TimerSource); +export const TextSelect = createIcon(TextSelectSource); export const Trash2 = createIcon(Trash2Source); +export const Undo2 = createIcon(Undo2Source); export const Upload = createIcon(UploadSource); export const User = createIcon(UserSource); export const Wifi = createIcon(WifiSource); diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index 05eb2ec27..13939c922 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -554,7 +554,10 @@ const DIFF_SELECTION_CONTEXT_MENU_MARGIN = 12; const GIT_HISTORY_PAGE_SIZE = 50; const GIT_HISTORY_LOAD_MORE_SCROLL_THRESHOLD_PX = 96; const CHANGE_CONTEXT_MENU_ITEM_CLASS = - "flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; + "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; +const CONTEXT_MENU_CONTAINER_CLASS = + "editor-context-menu select-none overflow-hidden rounded-xl border border-border/60 bg-popover/80 p-1 text-xs text-popover-foreground shadow-2xl ring-1 ring-black/[0.03] backdrop-blur-xl dark:ring-white/[0.06]"; +const CONTEXT_MENU_SEPARATOR_CLASS = "mx-1 my-1 h-px bg-border/60"; const GIT_REVIEW_POLL_INTERVAL_MS = 1500; type GitRefreshOptions = { @@ -1536,7 +1539,7 @@ function DiffContent(props: {
{ writeTextToClipboard(selectionContextMenu.selectedText); closeSelectionContextMenu(); @@ -4589,7 +4592,7 @@ export function GitReviewPanel(props: { (historyContextMenu.kind === "commit" || historyContextFile) ? (
event.stopPropagation()} onContextMenu={(event) => { @@ -4640,7 +4643,7 @@ export function GitReviewPanel(props: { {t("projectTools.gitReview.openOnGithub")} -
+
-
+
-
+
-
+
+
+ + ) : null} -
+
-
+
); @@ -1825,8 +1846,12 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
-
{t("projectTools.newTerminal")}
-
{t("projectTools.terminalDescription")}
+
+ {t("projectTools.newTerminal")} +
+
+ {t("projectTools.terminalDescription")} +
-
{t("projectTools.newFileTree")}
-
{t("projectTools.fileTreeDescription")}
+
+ {t("projectTools.newFileTree")} +
+
+ {t("projectTools.fileTreeDescription")} +
-
{t("projectTools.newGitReview")}
-
{t("projectTools.gitReviewDescription")}
+
+ {t("projectTools.newGitReview")} +
+
+ {t("projectTools.gitReviewDescription")} +
@@ -1861,9 +1894,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { {t("projectTools.loading")}
) : null} - {error ? ( -
{error}
- ) : null} + {error ?
{error}
: null}
) : ( <> @@ -1883,6 +1914,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { onInitializedChange={setFileTreeInitialized} onSyncStateChange={onFileTreeStateChange} onInsertFileMention={onInsertFileMention} + onOpenEditableFile={onOpenEditableFile} />
) : null} @@ -1929,16 +1961,24 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
-
{t("projectTools.newTerminal")}
+
+ {t("projectTools.newTerminal")} +
{terminalDisabledMessage ? (
{terminalDisabledMessage}
) : ( -
{t("projectTools.terminalDescription")}
+
+ {t("projectTools.terminalDescription")} +
)}
- {loading ? ( diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx new file mode 100644 index 000000000..c21673240 --- /dev/null +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -0,0 +1,1075 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import * as monaco from "monaco-editor"; +import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; +import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; +import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; +import { useLocale } from "@/i18n"; +import { cn } from "@/lib/shared/utils"; +import { + AlertTriangle, + ClipboardPaste, + Copy, + FilePenLine, + Loader2, + Redo2, + RefreshCw, + Replace, + Save, + Scissors, + Search, + TextSelect, + Undo2, + X, +} from "../icons"; +import type { IconComponent } from "../icons"; + +type MonacoEnvironmentGlobal = typeof globalThis & { + MonacoEnvironment?: { + getWorker: (workerId: string, label: string) => Worker; + }; +}; + +const monacoGlobal = globalThis as MonacoEnvironmentGlobal; + +if (!monacoGlobal.MonacoEnvironment) { + monacoGlobal.MonacoEnvironment = { + getWorker(_workerId, label) { + if (label === "json") return new JsonWorker(); + if (label === "css" || label === "scss" || label === "less") return new CssWorker(); + if (label === "html" || label === "handlebars" || label === "razor") { + return new HtmlWorker(); + } + if (label === "typescript" || label === "javascript") return new TsWorker(); + return new EditorWorker(); + }, + }; +} + +export type WorkspaceCodeEditorOpenRequest = { + id: number; + projectPathKey: string; + workdir: string; + path: string; +}; + +type ReadEditableTextResponse = { + path: string; + content: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; +}; + +type WriteTextResponse = { + path: string; + mtimeMs: number; + contentHash: string; + totalLines: number; +}; + +type EditorTabStatus = "ready" | "saving" | "conflict"; + +type EditorTab = { + key: string; + projectPathKey: string; + workdir: string; + path: string; + content: string; + savedContent: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; + language: string; + status: EditorTabStatus; + error: string | null; +}; + +type PendingDialog = + | { kind: "closeOverlay" } + | { kind: "closeTab"; tabKey: string } + | { kind: "reloadTab"; tabKey: string }; + +type EditorContextMenuState = { + x: number; + y: number; +}; + +const EDITOR_OVERLAY_ANIMATION_MS = 180; +const EDITOR_CONTEXT_MENU_WIDTH = 220; +const EDITOR_CONTEXT_MENU_HEIGHT = 300; + +type WorkspaceCodeEditorOverlayProps = { + openRequest: WorkspaceCodeEditorOpenRequest | null; + closeRequestId?: number; + theme: "light" | "dark"; + onClose: () => void; +}; + +function editorTabKey(projectPathKey: string, path: string) { + return `${projectPathKey}\u0000${path}`; +} + +function basename(path: string) { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} + +function dirname(path: string) { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index > 0 ? normalized.slice(0, index) : ""; +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function languageForPath(path: string) { + const name = basename(path).toLowerCase(); + if (name === "dockerfile") return "dockerfile"; + if (name === "makefile") return "makefile"; + if (name === "cargo.lock") return "toml"; + if (name.endsWith(".d.ts")) return "typescript"; + + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + switch (ext) { + case "js": + case "jsx": + case "mjs": + case "cjs": + return "javascript"; + case "ts": + case "tsx": + return "typescript"; + case "json": + case "jsonc": + return "json"; + case "css": + return "css"; + case "scss": + case "sass": + return "scss"; + case "less": + return "less"; + case "html": + case "htm": + return "html"; + case "md": + case "mdx": + return "markdown"; + case "rs": + return "rust"; + case "go": + return "go"; + case "py": + return "python"; + case "java": + return "java"; + case "kt": + case "kts": + return "kotlin"; + case "c": + case "h": + return "c"; + case "cc": + case "cpp": + case "cxx": + case "hpp": + return "cpp"; + case "cs": + return "csharp"; + case "php": + return "php"; + case "rb": + return "ruby"; + case "swift": + return "swift"; + case "sh": + case "bash": + case "zsh": + return "shell"; + case "yml": + case "yaml": + return "yaml"; + case "toml": + return "toml"; + case "xml": + case "svg": + return "xml"; + case "sql": + return "sql"; + case "graphql": + case "gql": + return "graphql"; + default: + return "plaintext"; + } +} + +function isVersionConflict(error: unknown) { + const message = error instanceof Error ? error.message : String(error ?? ""); + return message.includes("File changed since the last full Read"); +} + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) return error.message; + const text = String(error ?? "").trim(); + return text || fallback; +} + +function editorModelUri(tabKey: string) { + const bytes = new TextEncoder().encode(tabKey); + let hexKey = ""; + for (const byte of bytes) { + hexKey += byte.toString(16).padStart(2, "0"); + } + return monaco.Uri.from({ + scheme: "liveagent-editor", + authority: "model", + path: `/${hexKey}`, + }); +} + +function isMacLikePlatform() { + if (typeof navigator === "undefined") return false; + const platform = `${navigator.userAgent} ${navigator.platform}`; + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +function getEditorContextMenuShortcuts() { + const isMac = isMacLikePlatform(); + return { + undo: isMac ? "⌘Z" : "Ctrl+Z", + redo: isMac ? "⌘⇧Z" : "Ctrl+Y", + cut: isMac ? "⌘X" : "Ctrl+X", + copy: isMac ? "⌘C" : "Ctrl+C", + paste: isMac ? "⌘V" : "Ctrl+V", + selectAll: isMac ? "⌘A" : "Ctrl+A", + find: isMac ? "⌘F" : "Ctrl+F", + replace: isMac ? "⌥⌘F" : "Ctrl+H", + } as const; +} + +export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProps) { + const { openRequest, closeRequestId, theme, onClose } = props; + const { t } = useLocale(); + const contextMenuShortcuts = useMemo(() => getEditorContextMenuShortcuts(), []); + const overlayRef = useRef(null); + const containerRef = useRef(null); + const editorRef = useRef(null); + const modelsRef = useRef(new Map()); + const viewStatesRef = useRef(new Map()); + const editorModelKeyRef = useRef(""); + const activeKeyRef = useRef(""); + const openRequestIdRef = useRef(null); + const closeRequestIdRef = useRef(null); + const openAnimationFrameRef = useRef(null); + const closeAnimationTimeoutRef = useRef(null); + const initialThemeRef = useRef(theme); + const [tabs, setTabs] = useState([]); + const [activeKey, setActiveKey] = useState(""); + const [openingPaths, setOpeningPaths] = useState([]); + const [globalError, setGlobalError] = useState(null); + const [pendingDialog, setPendingDialog] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.key === activeKey) ?? tabs[0] ?? null, + [activeKey, tabs], + ); + const dirtyTabs = useMemo(() => tabs.filter((tab) => tab.content !== tab.savedContent), [tabs]); + const hasDirtyTabs = dirtyTabs.length > 0; + const isOpening = openingPaths.length > 0; + + useEffect(() => { + openAnimationFrameRef.current = window.requestAnimationFrame(() => { + openAnimationFrameRef.current = null; + setIsVisible(true); + }); + return () => { + if (openAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(openAnimationFrameRef.current); + } + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + } + }; + }, []); + + const cancelPendingClose = useCallback(() => { + if (closeAnimationTimeoutRef.current === null) return; + window.clearTimeout(closeAnimationTimeoutRef.current); + closeAnimationTimeoutRef.current = null; + setIsVisible(true); + }, []); + + const finishClose = useCallback(() => { + if (closeAnimationTimeoutRef.current !== null) return; + setIsVisible(false); + closeAnimationTimeoutRef.current = window.setTimeout(() => { + closeAnimationTimeoutRef.current = null; + onClose(); + }, EDITOR_OVERLAY_ANIMATION_MS); + }, [onClose]); + + const updateTab = useCallback((tabKey: string, updater: (tab: EditorTab) => EditorTab) => { + setTabs((current) => current.map((tab) => (tab.key === tabKey ? updater(tab) : tab))); + }, []); + + const disposeModel = useCallback((tabKey: string) => { + const model = modelsRef.current.get(tabKey); + if (model) { + if (editorRef.current?.getModel() === model) { + editorRef.current.setModel(null); + } + model.dispose(); + modelsRef.current.delete(tabKey); + } + if (editorModelKeyRef.current === tabKey) { + editorModelKeyRef.current = ""; + } + viewStatesRef.current.delete(tabKey); + }, []); + + const saveTab = useCallback( + async (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab || tab.content === tab.savedContent || tab.status === "saving") return true; + if (tab.status === "conflict") { + const message = tab.error ?? t("workspaceEditor.conflictMessage"); + setGlobalError(message); + return false; + } + + const contentToSave = tab.content; + updateTab(tabKey, (current) => ({ ...current, status: "saving", error: null })); + try { + const response = await invoke("fs_write_text", { + workdir: tab.workdir, + path: tab.path, + content: contentToSave, + mode: "rewrite", + expected_mtime_ms: tab.mtimeMs, + expected_content_hash: tab.contentHash, + }); + updateTab(tabKey, (current) => ({ + ...current, + savedContent: contentToSave, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + totalLines: current.content === contentToSave ? response.totalLines : current.totalLines, + sizeBytes: new TextEncoder().encode(current.content).length, + status: "ready", + error: null, + })); + setGlobalError(null); + return true; + } catch (error) { + const conflict = isVersionConflict(error); + const message = conflict + ? t("workspaceEditor.conflictMessage") + : toMessage(error, t("workspaceEditor.saveFailed")); + updateTab(tabKey, (current) => ({ + ...current, + status: conflict ? "conflict" : "ready", + error: message, + })); + setGlobalError(message); + return false; + } + }, + [t, tabs, updateTab], + ); + + const readTab = useCallback( + async (request: WorkspaceCodeEditorOpenRequest) => { + const key = editorTabKey(request.projectPathKey, request.path); + const existing = tabs.find((tab) => tab.key === key); + if (existing) { + setActiveKey(key); + setGlobalError(null); + return; + } + + setOpeningPaths((current) => [ + ...current.filter((item) => item !== request.path), + request.path, + ]); + setGlobalError(null); + try { + const response = await invoke("fs_read_editable_text", { + workdir: request.workdir, + path: request.path, + }); + const nextTab: EditorTab = { + key, + projectPathKey: request.projectPathKey, + workdir: request.workdir, + path: response.path, + content: response.content, + savedContent: response.content, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + sizeBytes: response.sizeBytes, + totalLines: response.totalLines, + language: languageForPath(response.path), + status: "ready", + error: null, + }; + setTabs((current) => { + if (current.some((tab) => tab.key === key)) return current; + return [...current, nextTab]; + }); + setActiveKey(key); + } catch (error) { + setGlobalError(toMessage(error, t("workspaceEditor.openFailed"))); + } finally { + setOpeningPaths((current) => current.filter((item) => item !== request.path)); + } + }, + [t, tabs], + ); + + const reloadTab = useCallback( + async (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return false; + setOpeningPaths((current) => [...current.filter((item) => item !== tab.path), tab.path]); + setGlobalError(null); + try { + const response = await invoke("fs_read_editable_text", { + workdir: tab.workdir, + path: tab.path, + }); + const model = modelsRef.current.get(tabKey); + if (model && model.getValue() !== response.content) { + model.setValue(response.content); + } + updateTab(tabKey, (current) => ({ + ...current, + path: response.path, + content: response.content, + savedContent: response.content, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + sizeBytes: response.sizeBytes, + totalLines: response.totalLines, + language: languageForPath(response.path), + status: "ready", + error: null, + })); + return true; + } catch (error) { + const message = toMessage(error, t("workspaceEditor.reloadFailed")); + updateTab(tabKey, (current) => ({ ...current, error: message })); + setGlobalError(message); + return false; + } finally { + setOpeningPaths((current) => current.filter((item) => item !== tab.path)); + } + }, + [t, tabs, updateTab], + ); + + const closeTabNow = useCallback( + (tabKey: string) => { + disposeModel(tabKey); + setTabs((current) => { + const index = current.findIndex((tab) => tab.key === tabKey); + if (index < 0) return current; + const next = current.filter((tab) => tab.key !== tabKey); + setActiveKey((currentActive) => { + if (currentActive !== tabKey) return currentActive; + return next[Math.min(index, next.length - 1)]?.key ?? ""; + }); + if (next.length === 0) { + window.requestAnimationFrame(finishClose); + } + return next; + }); + }, + [disposeModel, finishClose], + ); + + const requestCloseTab = useCallback( + (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return; + if (tab.content !== tab.savedContent) { + setPendingDialog({ kind: "closeTab", tabKey }); + return; + } + closeTabNow(tabKey); + }, + [closeTabNow, tabs], + ); + + const requestReloadTab = useCallback( + (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return; + if (tab.status !== "conflict" && tab.content !== tab.savedContent) { + setPendingDialog({ kind: "reloadTab", tabKey }); + return; + } + void reloadTab(tabKey); + }, + [reloadTab, tabs], + ); + + const requestCloseOverlay = useCallback(() => { + if (hasDirtyTabs) { + setPendingDialog({ kind: "closeOverlay" }); + return; + } + finishClose(); + }, [finishClose, hasDirtyTabs]); + + useEffect(() => { + if (closeRequestId == null) return; + if (closeRequestIdRef.current == null) { + closeRequestIdRef.current = closeRequestId; + return; + } + if (closeRequestIdRef.current === closeRequestId) return; + closeRequestIdRef.current = closeRequestId; + requestCloseOverlay(); + }, [closeRequestId, requestCloseOverlay]); + + const discardDialogTarget = useCallback(() => { + const dialog = pendingDialog; + setPendingDialog(null); + if (!dialog) return; + if (dialog.kind === "closeOverlay") { + finishClose(); + return; + } + if (dialog.kind === "closeTab") { + closeTabNow(dialog.tabKey); + return; + } + void reloadTab(dialog.tabKey); + }, [closeTabNow, finishClose, pendingDialog, reloadTab]); + + const saveDialogTarget = useCallback(() => { + const dialog = pendingDialog; + if (!dialog) return; + void (async () => { + if (dialog.kind === "closeOverlay") { + for (const tab of dirtyTabs) { + const saved = await saveTab(tab.key); + if (!saved) return; + } + setPendingDialog(null); + finishClose(); + return; + } + const saved = await saveTab(dialog.tabKey); + if (!saved) return; + setPendingDialog(null); + if (dialog.kind === "closeTab") { + closeTabNow(dialog.tabKey); + } else { + void reloadTab(dialog.tabKey); + } + })(); + }, [closeTabNow, dirtyTabs, finishClose, pendingDialog, reloadTab, saveTab]); + + const showFind = useCallback(() => { + editorRef.current?.focus(); + editorRef.current?.trigger("toolbar", "actions.find", null); + }, []); + + const showReplace = useCallback(() => { + editorRef.current?.focus(); + editorRef.current?.trigger("toolbar", "editor.action.startFindReplaceAction", null); + }, []); + + const runEditorCommand = useCallback((commandId: string) => { + setContextMenu(null); + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", commandId, null); + }, []); + + const openEditorContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!activeTab || pendingDialog) return; + event.preventDefault(); + event.stopPropagation(); + editorRef.current?.focus(); + + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect) return; + const maxX = Math.max(8, rect.width - EDITOR_CONTEXT_MENU_WIDTH - 8); + const maxY = Math.max(8, rect.height - EDITOR_CONTEXT_MENU_HEIGHT - 8); + setContextMenu({ + x: Math.min(Math.max(event.clientX - rect.left, 8), maxX), + y: Math.min(Math.max(event.clientY - rect.top, 8), maxY), + }); + }, + [activeTab, pendingDialog], + ); + + useEffect(() => { + if (!openRequest || openRequestIdRef.current === openRequest.id) return; + openRequestIdRef.current = openRequest.id; + cancelPendingClose(); + void readTab(openRequest); + }, [cancelPendingClose, openRequest, readTab]); + + useEffect(() => { + activeKeyRef.current = activeTab?.key ?? ""; + }, [activeTab?.key]); + + useEffect(() => { + const container = containerRef.current; + if (!container || editorRef.current) return; + const editor = monaco.editor.create(container, { + automaticLayout: true, + fontSize: 13, + fontLigatures: true, + minimap: { enabled: true }, + model: null, + contextmenu: false, + scrollBeyondLastLine: false, + smoothScrolling: true, + tabSize: 2, + theme: initialThemeRef.current === "dark" ? "vs-dark" : "vs", + }); + editorRef.current = editor; + return () => { + editor.dispose(); + editorRef.current = null; + for (const model of modelsRef.current.values()) { + model.dispose(); + } + modelsRef.current.clear(); + viewStatesRef.current.clear(); + }; + }, []); + + useEffect(() => { + monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "vs"); + }, [theme]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor || !activeTab) { + editorRef.current?.setModel(null); + return; + } + + const previousKey = editorModelKeyRef.current; + if (previousKey && previousKey !== activeTab.key) { + viewStatesRef.current.set(previousKey, editor.saveViewState()); + } + + let model = modelsRef.current.get(activeTab.key); + if (!model) { + model = monaco.editor.createModel( + activeTab.content, + activeTab.language, + editorModelUri(activeTab.key), + ); + model.onDidChangeContent(() => { + const value = model?.getValue() ?? ""; + const lineCount = model?.getLineCount() ?? 0; + setTabs((current) => + current.map((tab) => + tab.key === activeTab.key + ? { ...tab, content: value, totalLines: lineCount, error: null } + : tab, + ), + ); + }); + modelsRef.current.set(activeTab.key, model); + } + if (model.getLanguageId() !== activeTab.language) { + monaco.editor.setModelLanguage(model, activeTab.language); + } + if (editor.getModel() !== model) { + editor.setModel(model); + const viewState = viewStatesRef.current.get(activeTab.key); + if (viewState) { + editor.restoreViewState(viewState); + } + editor.focus(); + } + editorModelKeyRef.current = activeTab.key; + }, [activeTab]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenu(null); + } + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return; + const currentKey = activeKeyRef.current; + if (!currentKey) return; + event.preventDefault(); + void saveTab(currentKey); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [saveTab]); + + useEffect(() => { + if (!contextMenu) return; + const closeContextMenu = () => setContextMenu(null); + window.addEventListener("click", closeContextMenu); + window.addEventListener("blur", closeContextMenu); + window.addEventListener("resize", closeContextMenu); + return () => { + window.removeEventListener("click", closeContextMenu); + window.removeEventListener("blur", closeContextMenu); + window.removeEventListener("resize", closeContextMenu); + }; + }, [contextMenu]); + + const dialogTitle = + pendingDialog?.kind === "closeOverlay" + ? t("workspaceEditor.closeDirtyTitle") + : pendingDialog?.kind === "reloadTab" + ? t("workspaceEditor.reloadDirtyTitle") + : t("workspaceEditor.closeTabDirtyTitle"); + const dialogDescription = + pendingDialog?.kind === "closeOverlay" + ? t("workspaceEditor.closeDirtyDescription") + : pendingDialog?.kind === "reloadTab" + ? t("workspaceEditor.reloadDirtyDescription") + : t("workspaceEditor.closeTabDirtyDescription"); + + return ( +
+
+ +
+
+ {t("workspaceEditor.title")} +
+
+ {activeTab ? activeTab.path : t("workspaceEditor.empty")} +
+
+
+ activeTab && void saveTab(activeTab.key)} + > + {activeTab?.status === "saving" ? ( + + ) : ( + + )} + + + + + + + + activeTab && requestReloadTab(activeTab.key)} + > + + + + + +
+
+ +
+ {tabs.map((tab) => { + const dirty = tab.content !== tab.savedContent; + return ( + + ); + })} +
+ + {globalError || activeTab?.error ? ( +
+ +
{activeTab?.error ?? globalError}
+ {activeTab?.status === "conflict" ? ( + + ) : null} +
+ ) : null} + +
+
+ {!activeTab ? ( +
+ {isOpening ? ( + + ) : ( + + )} +
{isOpening ? t("workspaceEditor.opening") : t("workspaceEditor.emptyHint")}
+ {globalError ? ( +
+ {globalError} +
+ ) : null} +
+ ) : null} +
+ + {contextMenu ? ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + onMouseDown={(event) => event.preventDefault()} + > + runEditorCommand("undo")} + /> + runEditorCommand("redo")} + /> + + runEditorCommand("editor.action.clipboardCutAction")} + /> + runEditorCommand("editor.action.clipboardCopyAction")} + /> + runEditorCommand("editor.action.clipboardPasteAction")} + /> + + runEditorCommand("editor.action.selectAll")} + /> + + { + setContextMenu(null); + showFind(); + }} + /> + { + setContextMenu(null); + showReplace(); + }} + /> +
+ ) : null} + +
+ + {activeTab ? dirname(activeTab.path) || "/" : t("workspaceEditor.noFile")} + + + {activeTab + ? `${activeTab.language} · ${activeTab.totalLines} ${t("workspaceEditor.lines")} · ${formatBytes(activeTab.sizeBytes)}` + : ""} + + {activeTab?.content !== activeTab?.savedContent ? ( + {t("workspaceEditor.unsaved")} + ) : null} +
+ + {pendingDialog ? ( +
+
+
{dialogTitle}
+
{dialogDescription}
+
+ + + +
+
+
+ ) : null} +
+ ); +} + +function ContextMenuItem(props: { + icon?: IconComponent; + label: string; + shortcut?: string; + onClick: () => void; +}) { + const Icon = props.icon; + return ( + + ); +} + +function ContextMenuSeparator() { + return
; +} + +function IconButton(props: { + label: string; + disabled?: boolean; + children: ReactNode; + onClick: () => void; +}) { + const { label, disabled = false, children, onClick } = props; + return ( + + ); +} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 702906238..63eea6459 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -250,7 +250,8 @@ export const translations: Record> = { "projectTools.gitReview.unstageAllChanges": "取消暂存所有更改", "projectTools.gitReview.discardAllChanges": "放弃所有更改", "projectTools.gitReview.refreshChanges": "刷新更改", - "projectTools.gitReview.discardAllConfirm": "放弃当前仓库中的所有本地更改?未跟踪文件也会被删除,此操作无法撤销。", + "projectTools.gitReview.discardAllConfirm": + "放弃当前仓库中的所有本地更改?未跟踪文件也会被删除,此操作无法撤销。", "projectTools.gitReview.largeDiff": "大文件", "projectTools.gitReview.diffPreviewTruncated": "... 文件 diff 预览已截断 ...", "projectTools.gitReview.noDiff": "没有 diff。", @@ -287,9 +288,12 @@ export const translations: Record> = { "projectTools.gitReview.discardAllSuccessMessage": "工作区和未跟踪文件已清理。", "projectTools.gitReview.discardAllFailedTitle": "放弃所有更改失败", "projectTools.gitReview.remoteSetupTitle": "设置远端仓库", - "projectTools.gitReview.remoteSetupDescriptionFetch": "当前仓库还没有远端仓库。填写仓库地址后会保存为 origin,并获取远端分支。", - "projectTools.gitReview.remoteSetupDescriptionPull": "当前分支还没有 upstream,且没有 origin remote。填写仓库地址后会保存为 origin,并尝试从同名远端分支拉取。", - "projectTools.gitReview.remoteSetupDescriptionPush": "当前分支还没有 upstream,且没有 origin remote。填写仓库地址后会保存为 origin,并推送当前分支。", + "projectTools.gitReview.remoteSetupDescriptionFetch": + "当前仓库还没有远端仓库。填写仓库地址后会保存为 origin,并获取远端分支。", + "projectTools.gitReview.remoteSetupDescriptionPull": + "当前分支还没有 upstream,且没有 origin remote。填写仓库地址后会保存为 origin,并尝试从同名远端分支拉取。", + "projectTools.gitReview.remoteSetupDescriptionPush": + "当前分支还没有 upstream,且没有 origin remote。填写仓库地址后会保存为 origin,并推送当前分支。", "projectTools.gitReview.remoteUrl": "仓库地址", "projectTools.gitReview.remoteUrlPlaceholder": "https://github.com/owner/repo.git", "projectTools.gitReview.remoteUrlRequired": "请输入仓库地址。", @@ -336,7 +340,8 @@ export const translations: Record> = { "projectTools.gitReview.openOnGithub": "在 GitHub 上打开", "projectTools.gitReview.createBranch": "创建分支", "projectTools.gitReview.createBranchFromCommitTitle": "从提交创建分支", - "projectTools.gitReview.createBranchFromCommitDescription": "从 {sha} 创建并切换到新分支:{subject}", + "projectTools.gitReview.createBranchFromCommitDescription": + "从 {sha} 创建并切换到新分支:{subject}", "projectTools.gitReview.branchName": "分支名", "projectTools.gitReview.branchNamePlaceholder": "commit/abcdef0", "projectTools.gitReview.branchNameRequired": "请输入分支名。", @@ -373,7 +378,8 @@ export const translations: Record> = { "git.branchSelector.createNewBranch": "创建新分支", "git.branchSelector.initRepository": "初始化仓库", "git.branchSelector.initRepositoryTitle": "初始化 Git 仓库", - "git.branchSelector.initRepositoryDescription": "在当前目录创建 .git,并写入可选的本地 Git 用户信息。", + "git.branchSelector.initRepositoryDescription": + "在当前目录创建 .git,并写入可选的本地 Git 用户信息。", "git.branchSelector.targetDirectory": "目标目录", "git.branchSelector.initialBranch": "初始分支", "git.branchSelector.initialBranchRequired": "请输入初始分支名。", @@ -404,11 +410,48 @@ export const translations: Record> = { "projectTools.fileTree.resultsTruncated": "结果已截断", "projectTools.fileTree.newFile": "新建文件", "projectTools.fileTree.newFolder": "新建文件夹", + "projectTools.fileTree.openFile": "打开文件", "projectTools.fileTree.rename": "重命名", "projectTools.fileTree.delete": "删除", "projectTools.fileTree.copyPath": "复制路径", "projectTools.fileTree.copiedPath": "已复制路径", "projectTools.fileTree.insertReference": "插入引用", + "workspaceEditor.loading": "正在加载编辑器...", + "workspaceEditor.title": "代码编辑器", + "workspaceEditor.empty": "未打开文件", + "workspaceEditor.emptyHint": "从右侧文件树双击可编辑文件", + "workspaceEditor.opening": "正在打开文件...", + "workspaceEditor.save": "保存", + "workspaceEditor.saveAll": "全部保存", + "workspaceEditor.find": "查找", + "workspaceEditor.replace": "替换", + "workspaceEditor.reload": "重新加载", + "workspaceEditor.reloadFromDisk": "重新加载磁盘版本", + "workspaceEditor.close": "关闭编辑器", + "workspaceEditor.closeTab": "关闭文件", + "workspaceEditor.context.undo": "撤销", + "workspaceEditor.context.redo": "重做", + "workspaceEditor.context.cut": "剪切", + "workspaceEditor.context.copy": "复制", + "workspaceEditor.context.paste": "粘贴", + "workspaceEditor.context.selectAll": "全选", + "workspaceEditor.noFile": "无文件", + "workspaceEditor.lines": "行", + "workspaceEditor.unsaved": "未保存", + "workspaceEditor.openFailed": "打开文件失败", + "workspaceEditor.saveFailed": "保存失败", + "workspaceEditor.reloadFailed": "重新加载失败", + "workspaceEditor.conflictMessage": "文件已在磁盘上改变,请重新加载后再保存。", + "workspaceEditor.cancel": "取消", + "workspaceEditor.discard": "放弃修改", + "workspaceEditor.closeDirtyTitle": "关闭编辑器前保存修改?", + "workspaceEditor.closeDirtyDescription": + "还有未保存的文件。你可以保存全部修改、放弃修改,或返回继续编辑。", + "workspaceEditor.closeTabDirtyTitle": "关闭文件前保存修改?", + "workspaceEditor.closeTabDirtyDescription": + "此文件有未保存修改。你可以保存、放弃修改,或返回继续编辑。", + "workspaceEditor.reloadDirtyTitle": "重新加载前放弃当前修改?", + "workspaceEditor.reloadDirtyDescription": "重新加载会用磁盘版本替换当前编辑内容。", /* ── Settings Nav ── */ "settings.navSystem": "系统设置", @@ -432,7 +475,8 @@ export const translations: Record> = { "settings.memoryQuotaFull": "已满", "settings.memoryQuotaWarningMessage": "记忆用量已超过 80%,建议优先合并或删除低价值条目。", "settings.memoryQuotaNearLimitMessage": "记忆用量已超过 95%,新增记忆很快会被拒绝。", - "settings.memoryQuotaFullMessage": "当前 scope 已达到 500 条普通记忆上限;新增记忆会被拒绝,请先删除或合并旧条目。", + "settings.memoryQuotaFullMessage": + "当前 scope 已达到 500 条普通记忆上限;新增记忆会被拒绝,请先删除或合并旧条目。", "settings.memoryRefresh": "刷新", "settings.memoryOpenSettings": "记忆设置", "settings.memorySettingsTitle": "记忆设置", @@ -484,18 +528,22 @@ export const translations: Record> = { "settings.memoryOrganizerNoModel": "请先在「记忆整理」中选择一个模型。", "settings.memoryOrganizerAlreadyRunning": "已有记忆整理正在运行,请稍后查看历史记录。", "settings.memoryOrganizerQueued": "记忆整理已加入队列,将在后台执行。", - "settings.memoryOrganizerQueuedRemote": "记忆整理已提交到桌面端后台队列;请保持桌面端运行以执行整理。", + "settings.memoryOrganizerQueuedRemote": + "记忆整理已提交到桌面端后台队列;请保持桌面端运行以执行整理。", "settings.memoryOrganizerHistory": "历史记录", - "settings.memoryOrganizerHistoryDescription": "查看每次记忆整理后的模型最终总结、统计与裁剪协议。", + "settings.memoryOrganizerHistoryDescription": + "查看每次记忆整理后的模型最终总结、统计与裁剪协议。", "settings.memoryOrganizerBackToList": "返回历史列表", "settings.memoryOrganizerHistoryAll": "全部状态", "settings.memoryOrganizerHistoryEmpty": "暂无记忆整理历史。", "settings.memoryOrganizerHistoryPending": "整理运行中,完成后会显示模型最终总结。", "settings.memoryOrganizerClearHistory": "清空历史记录", "settings.memoryOrganizerClearHistoryConfirmTitle": "清空记忆整理历史?", - "settings.memoryOrganizerClearHistoryConfirmDescription": "将删除所有已结束的整理历史记录;正在排队或运行中的任务会保留,避免中断当前整理。此操作不可撤销。", + "settings.memoryOrganizerClearHistoryConfirmDescription": + "将删除所有已结束的整理历史记录;正在排队或运行中的任务会保留,避免中断当前整理。此操作不可撤销。", "settings.memoryOrganizerHistoryCleared": "历史记录已清空。", - "settings.memoryOrganizerHistoryClearedActiveRetained": "历史记录已清空;正在排队或运行中的任务已保留。", + "settings.memoryOrganizerHistoryClearedActiveRetained": + "历史记录已清空;正在排队或运行中的任务已保留。", "settings.memoryOrganizerStatusPending": "排队中", "settings.memoryOrganizerStatusRunning": "运行中", "settings.memoryOrganizerStatusSucceeded": "成功", @@ -519,7 +567,8 @@ export const translations: Record> = { "settings.memoryOrganizerClusterSummaries": "分组总结", "settings.memoryOrganizerTrimmedProtocol": "裁剪后的模型协议", "settings.memoryOrganizerManualPreview": "手动整理预览", - "settings.memoryOrganizerManualPreviewDescription": "默认选中客户端判定为安全的建议,确认后才会写入记忆。", + "settings.memoryOrganizerManualPreviewDescription": + "默认选中客户端判定为安全的建议,确认后才会写入记忆。", "settings.memoryOrganizerApplySelected": "应用选中建议", "settings.memoryOrganizerApplied": "选中建议已应用。", "settings.memoryOrganizerPartiallyApplied": "选中建议已部分应用;请查看失败项。", @@ -606,7 +655,8 @@ export const translations: Record> = { "settings.chinese": "简体中文", "settings.english": "English", "settings.executionMode": "执行模式", - "settings.executionModeDesc": "选择当前对话的运行方式。Chat 模式仅输出文本,Agent 模式允许模型调用工具执行操作。", + "settings.executionModeDesc": + "选择当前对话的运行方式。Chat 模式仅输出文本,Agent 模式允许模型调用工具执行操作。", "settings.chatMode": "Chat 模式", "settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown", "settings.agentMode": "Agent 模式", @@ -632,7 +682,8 @@ export const translations: Record> = { "settings.workdirWarning": "Agent 模式需要先选择项目,否则无法执行文件工具。", "settings.workdirOpenFailed": "打开目录选择器失败:", "settings.systemTools": "自定义系统工具", - "settings.systemToolsDesc": "这里仅展示用户自定义的系统工具;选中的工具会在 Agent 模式下注册,供模型在对话中调用。", + "settings.systemToolsDesc": + "这里仅展示用户自定义的系统工具;选中的工具会在 Agent 模式下注册,供模型在对话中调用。", "settings.noSystemTools": "暂无可用的自定义系统工具", /* ── Settings Providers ── */ @@ -757,9 +808,12 @@ export const translations: Record> = { "settings.cronPromptModelRequired": "请选择 Auto Prompt 要使用的模型。", "settings.cronPromptModelEmpty": "请先在供应商配置中至少启用一个模型。", "settings.cronPromptRequired": "请输入要执行的 Prompt 内容。", - "settings.cronPromptRunHint": "Auto Prompt 会在后台独立执行,不会进入主页面最近对话,只会将最终结论写入当前任务的日志列表。", - "settings.cronPromptAgentModeOnlyHint": "Auto Prompt 仅在系统执行模式为 Agent 模式或 Agent dev 模式时可运行。", - "settings.cronPromptAgentModeRequired": "请先将 系统设置 -> 执行模式 切换为 Agent 模式或 Agent dev 模式,再运行 Auto Prompt。", + "settings.cronPromptRunHint": + "Auto Prompt 会在后台独立执行,不会进入主页面最近对话,只会将最终结论写入当前任务的日志列表。", + "settings.cronPromptAgentModeOnlyHint": + "Auto Prompt 仅在系统执行模式为 Agent 模式或 Agent dev 模式时可运行。", + "settings.cronPromptAgentModeRequired": + "请先将 系统设置 -> 执行模式 切换为 Agent 模式或 Agent dev 模式,再运行 Auto Prompt。", "settings.cronPromptPlaceholder": "输入要执行的 Prompt 内容...", "settings.cronCommandList": "脚本", "settings.cronCommandHint": "输入普通 Shell 脚本,可多行", @@ -827,7 +881,8 @@ export const translations: Record> = { "settings.remoteGrpcPort": "gRPC 端口", "settings.remoteGrpcPortHint": "Gateway 上 gRPC 服务的监听端口,默认 50051", "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": "可选。Railway TCP Proxy 等场景可填写独立 gRPC 地址,留空则使用 Gateway 地址加 gRPC 端口。", + "settings.remoteGrpcEndpointHint": + "可选。Railway TCP Proxy 等场景可填写独立 gRPC 地址,留空则使用 Gateway 地址加 gRPC 端口。", "settings.remoteAuth": "身份认证", "settings.remoteToken": "访问令牌", "settings.remoteTokenPlaceholder": "输入与 Gateway 配置一致的 Token", @@ -839,13 +894,16 @@ export const translations: Record> = { "settings.remoteAutoReconnect": "自动重连", "settings.remoteAutoReconnectHint": "连接断开后自动尝试重新连接 Gateway", "settings.remoteWebTerminal": "允许 WebUI Terminal", - "settings.remoteWebTerminalHint": "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可启动并控制本机项目终端。", + "settings.remoteWebTerminalHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可启动并控制本机项目终端。", "settings.remoteWebGit": "允许 WebUI Git", - "settings.remoteWebGitHint": "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebGitHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", "settings.remoteHeartbeatHint": "与 Gateway 之间的心跳检测间隔,用于维持连接和检测在线状态", - "settings.remoteInfoBanner": "启用后,本地 LiveAgent 将通过 gRPC 双向流连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", + "settings.remoteInfoBanner": + "启用后,本地 LiveAgent 将通过 gRPC 双向流连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", @@ -913,7 +971,8 @@ export const translations: Record> = { "mcpHub.storeConfigureTitle": "配置 MCP Server", "mcpHub.storeConfigureSubtitle": "补全 {name} 的连接信息后添加到本地 MCP Servers", "mcpHub.storeConfigureRequiredTitle": "必填配置", - "mcpHub.storeConfigureRequiredDesc": "这些值会按 Registry 提供的目标写入 Env、Header、URL 或 Args。", + "mcpHub.storeConfigureRequiredDesc": + "这些值会按 Registry 提供的目标写入 Env、Header、URL 或 Args。", "mcpHub.storeConfigureSubmit": "添加到本地", "mcpHub.storeConfigureNameRequired": "请填写 Server Name", "mcpHub.storeConfigureCommandRequired": "请填写启动命令", @@ -978,7 +1037,8 @@ export const translations: Record> = { "settings.skillsDisabledInChatMode": "Chat 模式下不启用技能。切换到 Agent 模式后,才会恢复扫描、选择和注入能力。", "settings.skillsNotFound": "未发现任何技能", - "settings.skillsNotFoundHint": "在应用 Skills 目录中添加 skill.json、SKILL.md 或 README.md 文件", + "settings.skillsNotFoundHint": + "在应用 Skills 目录中添加 skill.json、SKILL.md 或 README.md 文件", "settings.skillsRescan": "重新扫描", "settings.skillsSearch": "搜索技能...", "settings.skillsNoMatch": "没有匹配「{filter}」的技能", @@ -1013,7 +1073,8 @@ export const translations: Record> = { "settings.skillsStorePreviewDownloads": "下载", "settings.skillsStorePreviewStars": "星标", "settings.skillsStorePreviewInstalls": "安装", - "settings.skillsStorePreviewDetailUnavailable": "ClawHub 详情暂不可用,已显示列表中的基础信息。", + "settings.skillsStorePreviewDetailUnavailable": + "ClawHub 详情暂不可用,已显示列表中的基础信息。", "settings.skillsStorePreviewMetadata": "信息", "settings.skillsStorePreviewOwner": "作者", "settings.skillsStorePreviewVersion": "版本", @@ -1126,12 +1187,14 @@ export const translations: Record> = { "chat.workspaceRename": "Rename", "chat.workspaceRemove": "Remove workspace", "chat.workspaceBrowseInFileTree": "Browse in File Tree", - "chat.workspaceRemoveConfirm": "Remove \"{name}\"?", - "chat.workspaceRemoveRunning": "A background task is running, so this workspace cannot be removed yet.", + "chat.workspaceRemoveConfirm": 'Remove "{name}"?', + "chat.workspaceRemoveRunning": + "A background task is running, so this workspace cannot be removed yet.", "chat.workspaceRemoveDescription": "This deletes conversations under the workspace, but it does not delete the folder.", "chat.exitConfirmRunningLabel": "Running Terminal sessions", - "chat.workspaceRemoveTerminalDescription": "Deleting the project will close these Terminal processes.", + "chat.workspaceRemoveTerminalDescription": + "Deleting the project will close these Terminal processes.", "chat.workspaceRemoveConfirmContinue": "Delete project", "chat.workspaceRemoveConfirmClose": "Close project deletion confirmation", "chat.conversationMore": "More actions", @@ -1140,7 +1203,7 @@ export const translations: Record> = { "chat.conversationRename": "Rename", "chat.conversationShare": "Share", "chat.conversationDelete": "Delete conversation", - "chat.conversationDeleteConfirm": "Delete \"{title}\"?", + "chat.conversationDeleteConfirm": 'Delete "{title}"?', "chat.conversationDeleteWarning": "This cannot be undone", "chat.statusPinned": "Pinned", "chat.statusShared": "Shared", @@ -1173,7 +1236,8 @@ export const translations: Record> = { "chat.runtime.reasoning": "Thinking effort", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to mention files, Enter to send, Shift+Enter for newline", - "chat.inputHintWithSkills": "Type a message, @ to mention files, $ to reference Skills, Enter to send, Shift+Enter for newline", + "chat.inputHintWithSkills": + "Type a message, @ to mention files, $ to reference Skills, Enter to send, Shift+Enter for newline", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", "chat.editMessage": "Edit Message", @@ -1288,7 +1352,8 @@ export const translations: Record> = { "sharedHistory.refresh": "Refresh", "sharedHistory.emptyFilteredTitle": "No shared conversations match", "sharedHistory.emptyTitle": "No shared conversations yet", - "sharedHistory.emptyFilteredDesc": "Try another keyword to search titles, models, or conversation paths.", + "sharedHistory.emptyFilteredDesc": + "Try another keyword to search titles, models, or conversation paths.", "sharedHistory.emptyDesc": "Shared conversations will appear here so you can review link state and disable public access.", "sharedHistory.timeUnknown": "Time unknown", @@ -1329,7 +1394,7 @@ export const translations: Record> = { "projectTools.confirmClose": "Confirm close", "projectTools.closeTerminal": "Close terminal", "projectTools.confirmCloseTerminal": "Confirm close terminal", - "projectTools.closeRunningTerminal": "Close running terminal \"{title}\"?", + "projectTools.closeRunningTerminal": 'Close running terminal "{title}"?', "projectTools.closeFileTree": "Close File Tree", "projectTools.closeGitReview": "Close Git Review", "projectTools.gitReview.viewChanges": "View Changes", @@ -1338,13 +1403,15 @@ export const translations: Record> = { "projectTools.gitReview.unstageChanges": "Unstage Changes", "projectTools.gitReview.addToGitignore": "Add to .gitignore", "projectTools.gitReview.revealInFileTree": "Reveal in File Tree", - "projectTools.gitReview.discardConfirm": "Discard local changes in \"{path}\"? This cannot be undone.", + "projectTools.gitReview.discardConfirm": + 'Discard local changes in "{path}"? This cannot be undone.', "projectTools.gitReview.changesActions": "Changes Actions", "projectTools.gitReview.stageAllChanges": "Stage All Changes", "projectTools.gitReview.unstageAllChanges": "Unstage All Changes", "projectTools.gitReview.discardAllChanges": "Discard All Changes", "projectTools.gitReview.refreshChanges": "Refresh Changes", - "projectTools.gitReview.discardAllConfirm": "Discard all local changes in this repository? Untracked files will also be deleted. This cannot be undone.", + "projectTools.gitReview.discardAllConfirm": + "Discard all local changes in this repository? Untracked files will also be deleted. This cannot be undone.", "projectTools.gitReview.largeDiff": "Large", "projectTools.gitReview.diffPreviewTruncated": "... file diff preview truncated ...", "projectTools.gitReview.noDiff": "No diff.", @@ -1366,24 +1433,31 @@ export const translations: Record> = { "projectTools.gitReview.pullSuccessMessage": "The current branch is synced with the remote.", "projectTools.gitReview.pullFailedTitle": "Pull failed", "projectTools.gitReview.pushSuccessTitle": "Push complete", - "projectTools.gitReview.pushSuccessMessage": "The current branch has been uploaded to the remote.", + "projectTools.gitReview.pushSuccessMessage": + "The current branch has been uploaded to the remote.", "projectTools.gitReview.pushFailedTitle": "Push failed", "projectTools.gitReview.commitSuccessTitle": "Commit complete", - "projectTools.gitReview.commitSuccessMessage": "A new local commit was created and the change list was refreshed.", + "projectTools.gitReview.commitSuccessMessage": + "A new local commit was created and the change list was refreshed.", "projectTools.gitReview.commitFailedTitle": "Commit failed", "projectTools.gitReview.createBranchSuccessTitle": "Branch created", - "projectTools.gitReview.createBranchSuccessMessage": "Created and switched to a new branch from the selected commit.", + "projectTools.gitReview.createBranchSuccessMessage": + "Created and switched to a new branch from the selected commit.", "projectTools.gitReview.createBranchFailedTitle": "Create branch failed", "projectTools.gitReview.discardSuccessTitle": "Changes discarded", "projectTools.gitReview.discardSuccessMessage": "The selected file changes were restored.", "projectTools.gitReview.discardFailedTitle": "Discard failed", "projectTools.gitReview.discardAllSuccessTitle": "All changes discarded", - "projectTools.gitReview.discardAllSuccessMessage": "Working tree changes and untracked files were cleaned.", + "projectTools.gitReview.discardAllSuccessMessage": + "Working tree changes and untracked files were cleaned.", "projectTools.gitReview.discardAllFailedTitle": "Discard all failed", "projectTools.gitReview.remoteSetupTitle": "Set Remote Repository", - "projectTools.gitReview.remoteSetupDescriptionFetch": "Remote repository is not configured. Enter a repository URL to save it as origin and fetch remote branches.", - "projectTools.gitReview.remoteSetupDescriptionPull": "The current branch has no upstream and no origin remote. Enter a repository URL to save it as origin and pull from the same-named remote branch.", - "projectTools.gitReview.remoteSetupDescriptionPush": "The current branch has no upstream and no origin remote. Enter a repository URL to save it as origin and push the current branch.", + "projectTools.gitReview.remoteSetupDescriptionFetch": + "Remote repository is not configured. Enter a repository URL to save it as origin and fetch remote branches.", + "projectTools.gitReview.remoteSetupDescriptionPull": + "The current branch has no upstream and no origin remote. Enter a repository URL to save it as origin and pull from the same-named remote branch.", + "projectTools.gitReview.remoteSetupDescriptionPush": + "The current branch has no upstream and no origin remote. Enter a repository URL to save it as origin and push the current branch.", "projectTools.gitReview.remoteUrl": "Repository URL", "projectTools.gitReview.remoteUrlPlaceholder": "https://github.com/owner/repo.git", "projectTools.gitReview.remoteUrlRequired": "Enter a repository URL.", @@ -1430,7 +1504,8 @@ export const translations: Record> = { "projectTools.gitReview.openOnGithub": "Open on GitHub", "projectTools.gitReview.createBranch": "Create Branch", "projectTools.gitReview.createBranchFromCommitTitle": "Create Branch from Commit", - "projectTools.gitReview.createBranchFromCommitDescription": "Create and switch to a new branch from {sha}: {subject}", + "projectTools.gitReview.createBranchFromCommitDescription": + "Create and switch to a new branch from {sha}: {subject}", "projectTools.gitReview.branchName": "Branch name", "projectTools.gitReview.branchNamePlaceholder": "commit/abcdef0", "projectTools.gitReview.branchNameRequired": "Enter a branch name.", @@ -1448,7 +1523,8 @@ export const translations: Record> = { "projectTools.gitReview.copyFilePath": "Copy File Path", "projectTools.gitReview.refreshHistory": "Refresh Commit History", "projectTools.gitReview.selectCommitToViewFiles": "Select a commit to view changed files.", - "projectTools.gitReview.selectCommitFileToViewDiff": "Select a file in the commit to view its diff.", + "projectTools.gitReview.selectCommitFileToViewDiff": + "Select a file in the commit to view its diff.", "projectTools.gitReview.commitFiles": "Changed Files", "projectTools.gitReview.commitDiff": "Commit Diff", "projectTools.gitReview.remoteRef": "remote", @@ -1467,7 +1543,8 @@ export const translations: Record> = { "git.branchSelector.createNewBranch": "Create New Branch", "git.branchSelector.initRepository": "Initialize Repository", "git.branchSelector.initRepositoryTitle": "Initialize Git Repository", - "git.branchSelector.initRepositoryDescription": "Create .git in the current directory and optionally save local Git user details.", + "git.branchSelector.initRepositoryDescription": + "Create .git in the current directory and optionally save local Git user details.", "git.branchSelector.targetDirectory": "Target Directory", "git.branchSelector.initialBranch": "Initial Branch", "git.branchSelector.initialBranchRequired": "Enter an initial branch name.", @@ -1482,7 +1559,7 @@ export const translations: Record> = { "projectTools.fileTree.searchFailed": "Search failed", "projectTools.fileTree.nameRequired": "Name is required", "projectTools.fileTree.actionFailed": "Action failed", - "projectTools.fileTree.deleteConfirm": "Delete \"{path}\"?", + "projectTools.fileTree.deleteConfirm": 'Delete "{path}"?', "projectTools.fileTree.deleteConfirmDescription": "This path will be deleted from disk. This action cannot be undone.", "projectTools.fileTree.deleteConfirmClose": "Close delete confirmation", @@ -1499,11 +1576,49 @@ export const translations: Record> = { "projectTools.fileTree.resultsTruncated": "Results truncated", "projectTools.fileTree.newFile": "New File", "projectTools.fileTree.newFolder": "New Folder", + "projectTools.fileTree.openFile": "Open File", "projectTools.fileTree.rename": "Rename", "projectTools.fileTree.delete": "Delete", "projectTools.fileTree.copyPath": "Copy Path", "projectTools.fileTree.copiedPath": "Copied Path", "projectTools.fileTree.insertReference": "Insert Reference", + "workspaceEditor.loading": "Loading editor...", + "workspaceEditor.title": "Code Editor", + "workspaceEditor.empty": "No file open", + "workspaceEditor.emptyHint": "Double-click an editable file in the file tree", + "workspaceEditor.opening": "Opening file...", + "workspaceEditor.save": "Save", + "workspaceEditor.saveAll": "Save All", + "workspaceEditor.find": "Find", + "workspaceEditor.replace": "Replace", + "workspaceEditor.reload": "Reload", + "workspaceEditor.reloadFromDisk": "Reload from disk", + "workspaceEditor.close": "Close editor", + "workspaceEditor.closeTab": "Close file", + "workspaceEditor.context.undo": "Undo", + "workspaceEditor.context.redo": "Redo", + "workspaceEditor.context.cut": "Cut", + "workspaceEditor.context.copy": "Copy", + "workspaceEditor.context.paste": "Paste", + "workspaceEditor.context.selectAll": "Select All", + "workspaceEditor.noFile": "No file", + "workspaceEditor.lines": "lines", + "workspaceEditor.unsaved": "Unsaved", + "workspaceEditor.openFailed": "Failed to open file", + "workspaceEditor.saveFailed": "Save failed", + "workspaceEditor.reloadFailed": "Reload failed", + "workspaceEditor.conflictMessage": "The file changed on disk. Reload it before saving.", + "workspaceEditor.cancel": "Cancel", + "workspaceEditor.discard": "Discard", + "workspaceEditor.closeDirtyTitle": "Save changes before closing the editor?", + "workspaceEditor.closeDirtyDescription": + "Some files have unsaved changes. Save all changes, discard them, or return to editing.", + "workspaceEditor.closeTabDirtyTitle": "Save changes before closing this file?", + "workspaceEditor.closeTabDirtyDescription": + "This file has unsaved changes. Save it, discard changes, or return to editing.", + "workspaceEditor.reloadDirtyTitle": "Discard current changes before reloading?", + "workspaceEditor.reloadDirtyDescription": + "Reloading replaces the current editor contents with the version on disk.", /* ── Settings Nav ── */ "settings.navSystem": "System", @@ -1525,9 +1640,12 @@ export const translations: Record> = { "settings.memoryQuotaWarning": "Near limit", "settings.memoryQuotaNearLimit": "Almost full", "settings.memoryQuotaFull": "Full", - "settings.memoryQuotaWarningMessage": "Memory usage is above 80%. Prefer merging or deleting low-value entries.", - "settings.memoryQuotaNearLimitMessage": "Memory usage is above 95%. New memories will soon be rejected.", - "settings.memoryQuotaFullMessage": "This scope has reached the 500 ordinary-memory limit. New memories will be rejected until old entries are deleted or merged.", + "settings.memoryQuotaWarningMessage": + "Memory usage is above 80%. Prefer merging or deleting low-value entries.", + "settings.memoryQuotaNearLimitMessage": + "Memory usage is above 95%. New memories will soon be rejected.", + "settings.memoryQuotaFullMessage": + "This scope has reached the 500 ordinary-memory limit. New memories will be rejected until old entries are deleted or merged.", "settings.memoryRefresh": "Refresh", "settings.memoryOpenSettings": "Memory settings", "settings.memorySettingsTitle": "Memory settings", @@ -1541,7 +1659,8 @@ export const translations: Record> = { "settings.memorySettingsCapacity": "Capacity", "settings.memorySettingsReview": "Review", "settings.memorySettingsDangerZone": "Danger zone", - "settings.memorySettingsWipeDescription": "Wipe moves existing memories to quarantine and rebuilds an empty memory store.", + "settings.memorySettingsWipeDescription": + "Wipe moves existing memories to quarantine and rebuilds an empty memory store.", "settings.memoryDriverModels": "Driver models", "settings.memoryOrganizerModel": "Memory organization", "settings.memorySummaryModel": "Conversation summary", @@ -1577,20 +1696,27 @@ export const translations: Record> = { "settings.memoryOrganizerNextRun": "Next automatic run:", "settings.memoryOrganizerRunNow": "Organize now", "settings.memoryOrganizerNoModel": "Choose a memory organization model first.", - "settings.memoryOrganizerAlreadyRunning": "Memory organization is already running. Check history again shortly.", - "settings.memoryOrganizerQueued": "Memory organization has been queued and will run in the background.", - "settings.memoryOrganizerQueuedRemote": "Memory organization has been submitted to the desktop background queue. Keep the desktop app running to execute it.", + "settings.memoryOrganizerAlreadyRunning": + "Memory organization is already running. Check history again shortly.", + "settings.memoryOrganizerQueued": + "Memory organization has been queued and will run in the background.", + "settings.memoryOrganizerQueuedRemote": + "Memory organization has been submitted to the desktop background queue. Keep the desktop app running to execute it.", "settings.memoryOrganizerHistory": "History", - "settings.memoryOrganizerHistoryDescription": "Review each memory organization run's final model summary, stats, and trimmed protocol.", + "settings.memoryOrganizerHistoryDescription": + "Review each memory organization run's final model summary, stats, and trimmed protocol.", "settings.memoryOrganizerBackToList": "Back to history list", "settings.memoryOrganizerHistoryAll": "All statuses", "settings.memoryOrganizerHistoryEmpty": "No memory organization history yet.", - "settings.memoryOrganizerHistoryPending": "Run in progress. The final model summary will appear after completion.", + "settings.memoryOrganizerHistoryPending": + "Run in progress. The final model summary will appear after completion.", "settings.memoryOrganizerClearHistory": "Clear history", "settings.memoryOrganizerClearHistoryConfirmTitle": "Clear memory organization history?", - "settings.memoryOrganizerClearHistoryConfirmDescription": "This deletes all finished memory organization history records. Pending or running tasks are retained so the current organization pass is not interrupted. This action cannot be undone.", + "settings.memoryOrganizerClearHistoryConfirmDescription": + "This deletes all finished memory organization history records. Pending or running tasks are retained so the current organization pass is not interrupted. This action cannot be undone.", "settings.memoryOrganizerHistoryCleared": "History has been cleared.", - "settings.memoryOrganizerHistoryClearedActiveRetained": "History has been cleared; pending or running tasks were retained.", + "settings.memoryOrganizerHistoryClearedActiveRetained": + "History has been cleared; pending or running tasks were retained.", "settings.memoryOrganizerStatusPending": "Pending", "settings.memoryOrganizerStatusRunning": "Running", "settings.memoryOrganizerStatusSucceeded": "Succeeded", @@ -1614,11 +1740,14 @@ export const translations: Record> = { "settings.memoryOrganizerClusterSummaries": "Cluster summaries", "settings.memoryOrganizerTrimmedProtocol": "Trimmed model protocol", "settings.memoryOrganizerManualPreview": "Manual preview", - "settings.memoryOrganizerManualPreviewDescription": "Safe client-validated suggestions are selected by default and are written only after confirmation.", + "settings.memoryOrganizerManualPreviewDescription": + "Safe client-validated suggestions are selected by default and are written only after confirmation.", "settings.memoryOrganizerApplySelected": "Apply selected", "settings.memoryOrganizerApplied": "Selected suggestions have been applied.", - "settings.memoryOrganizerPartiallyApplied": "Selected suggestions were partially applied; review failed items.", - "settings.memoryOrganizerApplyFailed": "Selected suggestions could not be written; review failed items.", + "settings.memoryOrganizerPartiallyApplied": + "Selected suggestions were partially applied; review failed items.", + "settings.memoryOrganizerApplyFailed": + "Selected suggestions could not be written; review failed items.", "settings.memoryOrganizerSelectAtLeastOne": "Select at least one suggestion.", "settings.memoryOrganizerDecisionDelete": "Delete", "settings.memoryOrganizerDecisionUpsert": "Rewrite", @@ -1679,7 +1808,8 @@ export const translations: Record> = { "settings.memoryAppendBlockPlaceholder": "Append memory block", "settings.memoryEmptyBody": "", "settings.memoryWipeConfirmTitle": "Wipe all memories?", - "settings.memoryWipeConfirmDescription": "Existing memories will be moved to quarantine and the memory store will be rebuilt empty.", + "settings.memoryWipeConfirmDescription": + "Existing memories will be moved to quarantine and the memory store will be rebuilt empty.", "settings.memoryWipeAll": "Wipe all", "settings.memorySelectEntry": "Select a memory entry.", @@ -1692,7 +1822,8 @@ export const translations: Record> = { /* ── Settings System ── */ "settings.appearance": "Appearance", - "settings.appearanceDesc": "Choose the color theme for the application. Your preference will be saved automatically.", + "settings.appearanceDesc": + "Choose the color theme for the application. Your preference will be saved automatically.", "settings.light": "Light", "settings.lightDesc": "Bright and clean light interface", "settings.dark": "Dark", @@ -1701,7 +1832,8 @@ export const translations: Record> = { "settings.chinese": "简体中文", "settings.english": "English", "settings.executionMode": "Execution Mode", - "settings.executionModeDesc": "Choose how the current conversation runs. Chat mode outputs text only; Agent mode allows the model to call tools.", + "settings.executionModeDesc": + "Choose how the current conversation runs. Chat mode outputs text only; Agent mode allows the model to call tools.", "settings.chatMode": "Chat Mode", "settings.chatModeDesc": "Plain text conversation, model outputs text and Markdown only", "settings.agentMode": "Agent Mode", @@ -1711,7 +1843,8 @@ export const translations: Record> = { "Same as Agent mode, but also writes each streaming request and response line-by-line to ~/.liveagent/debug/.jsonl", "settings.workdir": "Project Folder", "settings.workdirRequired": "Required", - "settings.workdirDesc": "Choose this project's folder. File tools read/write/search within the selected project directory.", + "settings.workdirDesc": + "Choose this project's folder. File tools read/write/search within the selected project directory.", "settings.workdirPlaceholder": "Select or enter a project folder path...", "settings.selectWorkdir": "Select Folder", "settings.workdirPickerTitle": "Select Project Folder", @@ -1727,7 +1860,8 @@ export const translations: Record> = { "settings.workdirWarning": "Agent mode requires selecting a project before file tools can run.", "settings.workdirOpenFailed": "Failed to open directory picker: ", "settings.systemTools": "Custom System Tools", - "settings.systemToolsDesc": "Only user-defined system tools are shown here. Selected tools are registered in Agent mode and can be called by the model during conversation.", + "settings.systemToolsDesc": + "Only user-defined system tools are shown here. Selected tools are registered in Agent mode and can be called by the model during conversation.", "settings.noSystemTools": "No custom system tools available", /* ── Settings Providers ── */ @@ -1789,8 +1923,10 @@ export const translations: Record> = { "settings.conversationTitleGeneration": "Conversation title generation", "settings.conversationTitleModel": "Title generation model", "settings.conversationTitleModelFollowCurrent": "Use current chat model", - "settings.conversationTitleModelHint": "When unselected, title generation uses the model from the current chat.", - "settings.customSettingsModelEmpty": "No active models are configured for the current providers.", + "settings.conversationTitleModelHint": + "When unselected, title generation uses the model from the current chat.", + "settings.customSettingsModelEmpty": + "No active models are configured for the current providers.", /* ── Settings Prompt ── */ "settings.agentsTitle": "Prompt", @@ -1800,7 +1936,8 @@ export const translations: Record> = { "settings.agentsName": "Name", "settings.agentsNamePlaceholder": "e.g. Code Review Assistant", "settings.agentsDescription": "Description", - "settings.agentsDescriptionPlaceholder": "Briefly describe what this global prompt template is for", + "settings.agentsDescriptionPlaceholder": + "Briefly describe what this global prompt template is for", "settings.agentsTags": "Tags", "settings.agentsTagsPlaceholder": "e.g. review, writing, global prompt", "settings.agentsPrompt": "Prompt", @@ -1809,7 +1946,8 @@ export const translations: Record> = { "settings.agentsActive": "active", "settings.agentsActiveLabel": "Active", "settings.agentsNoTemplates": "No global prompt templates yet", - "settings.agentsNoTemplatesHint": "Create reusable prompt templates to quickly apply common Agent instructions in your chats", + "settings.agentsNoTemplatesHint": + "Create reusable prompt templates to quickly apply common Agent instructions in your chats", "settings.agentsNoDescription": "No description", "settings.agentsNoTags": "No tags", "settings.agentsShowPrompt": "View Prompt", @@ -1822,7 +1960,7 @@ export const translations: Record> = { "settings.cronDesc": "Configure and manage automated scheduled tasks", "settings.cronAdd": "Add Task", "settings.cronEmpty": "No scheduled tasks", - "settings.cronEmptyDesc": "Click \"Add Task\" to create your first scheduled task", + "settings.cronEmptyDesc": 'Click "Add Task" to create your first scheduled task', "settings.cronCount": "tasks", "settings.cronTaskName": "Task Name", "settings.cronTaskNamePlaceholder": "Enter task name", @@ -1838,7 +1976,8 @@ export const translations: Record> = { "settings.cronRemainingExecutionsUnlimitedShort": "unlimited", "settings.cronRemainingExecutionsUnit": "remaining", "settings.cronRemainingExecutionsUnitShort": "left", - "settings.cronRemainingExecutionsEditRequired": "Run count is exhausted. Edit this task and change the run count first.", + "settings.cronRemainingExecutionsEditRequired": + "Run count is exhausted. Edit this task and change the run count first.", "settings.cronTaskType": "Task Type", "settings.cronTypeBash": "Shell Script", "settings.cronTypeBashHint": "Run a multi-line script with the platform shell", @@ -1852,9 +1991,12 @@ export const translations: Record> = { "settings.cronPromptModelRequired": "Select the model for this Auto Prompt task.", "settings.cronPromptModelEmpty": "Enable at least one model in provider settings first.", "settings.cronPromptRequired": "Enter the prompt content to execute.", - "settings.cronPromptRunHint": "Auto Prompt runs in the background, does not appear in recent conversations, and only writes the final conclusion to this task's log list.", - "settings.cronPromptAgentModeOnlyHint": "Auto Prompt runs only when System -> Execution Mode is Agent Mode or Agent Dev Mode.", - "settings.cronPromptAgentModeRequired": "Switch System -> Execution Mode to Agent Mode or Agent Dev Mode before running Auto Prompt.", + "settings.cronPromptRunHint": + "Auto Prompt runs in the background, does not appear in recent conversations, and only writes the final conclusion to this task's log list.", + "settings.cronPromptAgentModeOnlyHint": + "Auto Prompt runs only when System -> Execution Mode is Agent Mode or Agent Dev Mode.", + "settings.cronPromptAgentModeRequired": + "Switch System -> Execution Mode to Agent Mode or Agent Dev Mode before running Auto Prompt.", "settings.cronPromptPlaceholder": "Enter the prompt content to execute...", "settings.cronCommandList": "Script", "settings.cronCommandHint": "Enter a regular Shell script. Multiple lines are supported.", @@ -1862,13 +2004,15 @@ export const translations: Record> = { "settings.cronCommandRequired": "Script is required.", "settings.cronHttpRequests": "HTTP Requests", "settings.cronRequestsCount": "requests", - "settings.cronHttpHeadersInvalid": "Headers must be a JSON object and values will be stringified.", + "settings.cronHttpHeadersInvalid": + "Headers must be a JSON object and values will be stringified.", "settings.cronHttpBodyInvalid": "Body must be valid JSON.", "settings.cronHttpRequestRequired": "At least one HTTP request is required.", "settings.cronHttpUrlRequired": "Request URL is required", "settings.cronHttpUrlInvalid": "Request URL is invalid", "settings.cronHttpBodyDisabled": "This HTTP method does not support a request body here", - "settings.cronPromptUnavailable": "Auto Prompt is not implemented yet. Only Bash and HTTP tasks are supported now.", + "settings.cronPromptUnavailable": + "Auto Prompt is not implemented yet. Only Bash and HTTP tasks are supported now.", "settings.cronView": "View", "settings.cronEdit": "Edit", "settings.cronDelete": "Delete", @@ -1918,29 +2062,37 @@ export const translations: Record> = { "settings.remoteDisable": "Disable remote access", "settings.remoteGatewayConnection": "Gateway Connection", "settings.remoteGatewayUrl": "Gateway URL", - "settings.remoteGatewayUrlHint": "HTTPS address of the cloud Gateway for WebUI access and gRPC connection", + "settings.remoteGatewayUrlHint": + "HTTPS address of the cloud Gateway for WebUI access and gRPC connection", "settings.remoteGrpcPort": "gRPC Port", "settings.remoteGrpcPortHint": "The gRPC service port on the Gateway, default 50051", "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": "Optional. Use a separate gRPC address for Railway TCP Proxy or similar hosts. Leave empty to use the Gateway URL plus gRPC port.", + "settings.remoteGrpcEndpointHint": + "Optional. Use a separate gRPC address for Railway TCP Proxy or similar hosts. Leave empty to use the Gateway URL plus gRPC port.", "settings.remoteAuth": "Authentication", "settings.remoteToken": "Access Token", "settings.remoteTokenPlaceholder": "Enter the token matching Gateway config", - "settings.remoteTokenHint": "Must match the --token argument used when starting the Gateway, used for mutual authentication", + "settings.remoteTokenHint": + "Must match the --token argument used when starting the Gateway, used for mutual authentication", "settings.remoteAgentId": "Agent ID", "settings.remoteAgentIdPlaceholder": "e.g. macbook-pro", - "settings.remoteAgentIdHint": "Unique identifier for this Agent shown in WebUI. Leave empty to use hostname", + "settings.remoteAgentIdHint": + "Unique identifier for this Agent shown in WebUI. Leave empty to use hostname", "settings.remoteAdvanced": "Advanced Options", "settings.remoteAutoReconnect": "Auto Reconnect", "settings.remoteAutoReconnectHint": "Automatically reconnect to Gateway after connection drops", "settings.remoteWebTerminal": "Allow WebUI Terminal", - "settings.remoteWebTerminalHint": "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can start and control local project terminals.", + "settings.remoteWebTerminalHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can start and control local project terminals.", "settings.remoteWebGit": "Allow WebUI Git", - "settings.remoteWebGitHint": "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebGitHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", - "settings.remoteHeartbeatHint": "Heartbeat interval with Gateway for maintaining connection and detecting online status", - "settings.remoteInfoBanner": "When enabled, the local LiveAgent connects to the cloud Gateway via gRPC bidirectional streaming. You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", + "settings.remoteHeartbeatHint": + "Heartbeat interval with Gateway for maintaining connection and detecting online status", + "settings.remoteInfoBanner": + "When enabled, the local LiveAgent connects to the cloud Gateway via gRPC bidirectional streaming. You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", @@ -2006,9 +2158,11 @@ export const translations: Record> = { "mcpHub.storeManualOnly": "Manual", "mcpHub.storeConfigure": "Configure", "mcpHub.storeConfigureTitle": "Configure MCP Server", - "mcpHub.storeConfigureSubtitle": "Fill in connection details for {name} and add it to local MCP Servers", + "mcpHub.storeConfigureSubtitle": + "Fill in connection details for {name} and add it to local MCP Servers", "mcpHub.storeConfigureRequiredTitle": "Required Config", - "mcpHub.storeConfigureRequiredDesc": "Values are written to Env, Header, URL, or Args according to the Registry target.", + "mcpHub.storeConfigureRequiredDesc": + "Values are written to Env, Header, URL, or Args according to the Registry target.", "mcpHub.storeConfigureSubmit": "Add locally", "mcpHub.storeConfigureNameRequired": "Server Name is required", "mcpHub.storeConfigureCommandRequired": "Command is required", @@ -2033,7 +2187,8 @@ export const translations: Record> = { "mcpHub.storePreviewRemote": "Remote", "mcpHub.storePreviewLocal": "Local", "mcpHub.storePreviewLoadingDetail": "Loading details", - "mcpHub.storePreviewDetailUnavailable": "Details are unavailable. Showing the basic list information.", + "mcpHub.storePreviewDetailUnavailable": + "Details are unavailable. Showing the basic list information.", "mcpHub.storePreviewTags": "Tags", "mcpHub.storePreviewInstallPreview": "Install Preview", "mcpHub.storePreviewRequiredConfig": "Required Config", @@ -2055,7 +2210,8 @@ export const translations: Record> = { "settings.skillsHubToggleDisable": "Disable Skills", "settings.skillsHubInstalledTab": "Installed", "settings.skillsHubStoreTab": "Skills Store", - "settings.skillsHubScanning": "Scanning the fixed Skills directory and syncing available conversation capabilities", + "settings.skillsHubScanning": + "Scanning the fixed Skills directory and syncing available conversation capabilities", "settings.skillsHubDeleteSkill": "Delete Skill", "settings.skillsHubLoadFailed": "Failed to load skills", "settings.skillsHubStoreLoadFailed": "Failed to load Skills Store", @@ -2069,13 +2225,16 @@ export const translations: Record> = { "settings.skillsAlwaysOn": "Built-in", "settings.skillsScan": "Scan", "settings.skillsScanning": "Scanning", - "settings.skillsDisabledHint": "Skills is off. Enable it to inject selected skills into the system prompt.", - "settings.skillsDisabledInChatMode": "Skills stays disabled in Chat mode. Switch to Agent mode to scan, select, and inject skills again.", + "settings.skillsDisabledHint": + "Skills is off. Enable it to inject selected skills into the system prompt.", + "settings.skillsDisabledInChatMode": + "Skills stays disabled in Chat mode. Switch to Agent mode to scan, select, and inject skills again.", "settings.skillsNotFound": "No Skills found", - "settings.skillsNotFoundHint": "Add skill.json, SKILL.md, or README.md files to the Skills directory", + "settings.skillsNotFoundHint": + "Add skill.json, SKILL.md, or README.md files to the Skills directory", "settings.skillsRescan": "Rescan", "settings.skillsSearch": "Search Skills...", - "settings.skillsNoMatch": "No skills matching \"{filter}\"", + "settings.skillsNoMatch": 'No skills matching "{filter}"', "settings.skillsStoreSearch": "Search ClawHub Skills", "settings.skillsStoreSortMostDownloaded": "Most Downloaded", "settings.skillsStoreSortMostStarred": "Most Starred", @@ -2107,7 +2266,8 @@ export const translations: Record> = { "settings.skillsStorePreviewDownloads": "Downloads", "settings.skillsStorePreviewStars": "Stars", "settings.skillsStorePreviewInstalls": "Installs", - "settings.skillsStorePreviewDetailUnavailable": "ClawHub details are unavailable. Showing the basic list information.", + "settings.skillsStorePreviewDetailUnavailable": + "ClawHub details are unavailable. Showing the basic list information.", "settings.skillsStorePreviewMetadata": "Details", "settings.skillsStorePreviewOwner": "Owner", "settings.skillsStorePreviewVersion": "Version", @@ -2122,7 +2282,8 @@ export const translations: Record> = { /* ── Settings Hooks ── */ "settings.hooksTitle": "Hooks", - "settings.hooksDesc": "Configure Shell script or HTTP hooks for main conversation lifecycle events.", + "settings.hooksDesc": + "Configure Shell script or HTTP hooks for main conversation lifecycle events.", "settings.hooksCount": "hooks", "settings.hooksEnabledCount": "enabled", "settings.hooksLifecycle": "Lifecycle", @@ -2137,7 +2298,8 @@ export const translations: Record> = { "settings.hooksTypeHttp": "http", "settings.hooksNoDescription": "No description", "settings.hooksEmptyTitle": "No hooks configured for this lifecycle yet", - "settings.hooksEmptyDesc": "Add a hook to run Shell scripts or HTTP requests in sequence for this event.", + "settings.hooksEmptyDesc": + "Add a hook to run Shell scripts or HTTP requests in sequence for this event.", "settings.hooksScriptLinesCount": "script lines", "settings.hooksRequestsCount": "requests", "settings.hooksCommandList": "Script", @@ -2155,7 +2317,8 @@ export const translations: Record> = { "settings.hooksHttpBodyDisabled": "This HTTP method does not support a request body here", "settings.hooksHttpUrlRequired": "Request URL is required", "settings.hooksHttpUrlInvalid": "Request URL is invalid", - "settings.hooksHttpHeadersInvalid": "Headers must be a JSON object and values will be stringified.", + "settings.hooksHttpHeadersInvalid": + "Headers must be a JSON object and values will be stringified.", "settings.hooksHttpBodyInvalid": "Body must be valid JSON.", "settings.hooksHttpRequestRequired": "At least one HTTP request is required.", "settings.hooksNameRequired": "Hook name is required.", @@ -2164,11 +2327,14 @@ export const translations: Record> = { "settings.hooksEventTurnStart": "turn_start", "settings.hooksEventTurnStartDesc": "Fires when each model turn begins.", "settings.hooksEventMessageStart": "message_start", - "settings.hooksEventMessageStartDesc": "Fires when the current turn starts streaming a message.", + "settings.hooksEventMessageStartDesc": + "Fires when the current turn starts streaming a message.", "settings.hooksEventMessageUpdate": "message_update", - "settings.hooksEventMessageUpdateDesc": "Fires on every incremental message update in the current turn.", + "settings.hooksEventMessageUpdateDesc": + "Fires on every incremental message update in the current turn.", "settings.hooksEventMessageEnd": "message_end", - "settings.hooksEventMessageEndDesc": "Fires when the current turn finishes generating its message.", + "settings.hooksEventMessageEndDesc": + "Fires when the current turn finishes generating its message.", "settings.hooksEventToolExecutionStart": "tool_execution_start", "settings.hooksEventToolExecutionStartDesc": "Fires when a tool begins actual execution.", "settings.hooksEventToolExecutionUpdate": "tool_execution_update", diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index ed8862bfc..b98a66e3c 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -1720,3 +1720,19 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { -webkit-user-select: none; user-select: none; } + +@keyframes editorContextMenuIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.editor-context-menu { + animation: editorContextMenuIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 5ce63ff88..ec18d5c15 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -115,6 +115,15 @@ type FsWriteTextResponse = { totalLines: number; }; +type FsReadEditableTextResponse = { + path: string; + content: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; +}; + type FsCreateDirResponse = { path: string; kind: "dir"; @@ -343,15 +352,11 @@ function getRuntimeOrigin() { function readChatEventSeq(event: ChatEvent) { const seq = event.seq; - return typeof seq === "number" && Number.isFinite(seq) && seq > 0 - ? Math.floor(seq) - : 0; + return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; } function normalizeAfterSeq(value: unknown) { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : 0; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; } function normalizePositiveInteger(value: number, fallback: number) { @@ -615,9 +620,7 @@ export class GatewayWebSocketClient { execution_mode: systemSettings?.executionMode?.trim() || "text", workdir: systemSettings?.workdir?.trim() || "", selected_system_tools: - systemSettings?.selectedSystemTools - ?.map((item) => item.trim()) - .filter(Boolean) ?? [], + systemSettings?.selectedSystemTools?.map((item) => item.trim()).filter(Boolean) ?? [], uploaded_files: uploadedFiles?.map((file) => ({ relative_path: file.relativePath, @@ -930,20 +933,14 @@ export class GatewayWebSocketClient { }); } - async renameHistory( - conversationId: string, - title: string, - ): Promise { + async renameHistory(conversationId: string, title: string): Promise { return this.request("history.rename", { conversation_id: conversationId, title, }); } - async pinHistory( - conversationId: string, - isPinned: boolean, - ): Promise { + async pinHistory(conversationId: string, isPinned: boolean): Promise { return this.request("history.pin", { conversation_id: conversationId, is_pinned: isPinned, @@ -1020,10 +1017,7 @@ export class GatewayWebSocketClient { }); } - async createProjectFolder( - parent: string, - name: string, - ): Promise { + async createProjectFolder(parent: string, name: string): Promise { return this.request("fs.create_project_folder", { parent, name, @@ -1064,6 +1058,13 @@ export class GatewayWebSocketClient { }); } + async readEditableTextFile(workdir: string, path: string): Promise { + return this.request("fs.read_editable_text", { + workdir, + path, + }); + } + async createDir(workdir: string, path: string): Promise { return this.request("fs.create_dir", { workdir, path }); } @@ -1094,11 +1095,7 @@ export class GatewayWebSocketClient { return this.requestWithRecovery("skills.read-metadata", { path }); } - async readSkillText( - path: string, - offset?: number, - length?: number, - ): Promise { + async readSkillText(path: string, offset?: number, length?: number): Promise { return this.requestWithRecovery("skills.read-text", { path, offset, @@ -1106,11 +1103,7 @@ export class GatewayWebSocketClient { }); } - async getProviderModels( - type: string, - baseUrl: string, - apiKey: string, - ): Promise { + async getProviderModels(type: string, baseUrl: string, apiKey: string): Promise { return this.requestWithRecovery("provider.models", { type, base_url: baseUrl, @@ -1189,11 +1182,7 @@ export class GatewayWebSocketClient { } private scheduleReconnectNotice() { - if ( - this.reconnectNoticeTimer !== null || - this.disposed || - this.statusListeners.size === 0 - ) { + if (this.reconnectNoticeTimer !== null || this.disposed || this.statusListeners.size === 0) { return; } const host = getRuntimeHost(); @@ -1222,15 +1211,13 @@ export class GatewayWebSocketClient { return ( !this.disposed && this.token.trim() !== "" && - ( - this.chatStreams.size > 0 || + (this.chatStreams.size > 0 || this.pending.size > 0 || this.statusListeners.size > 0 || this.historyListeners.size > 0 || this.conversationListeners.size > 0 || this.settingsListeners.size > 0 || - this.terminalListeners.size > 0 - ) + this.terminalListeners.size > 0) ); } @@ -1253,9 +1240,7 @@ export class GatewayWebSocketClient { RECONNECT_INITIAL_DELAY_MS * 2 ** Math.min(this.reconnectAttempt, 5), ); const jitter = - baseDelay > 0 - ? Math.floor(Math.random() * Math.min(500, Math.max(1, baseDelay))) - : 0; + baseDelay > 0 ? Math.floor(Math.random() * Math.min(500, Math.max(1, baseDelay))) : 0; const host = getRuntimeHost(); this.reconnectTimer = host.setTimeout(() => { this.reconnectTimer = null; @@ -1706,7 +1691,9 @@ export class GatewayWebSocketClient { this.pending.delete(requestId); if (envelope.type === "error") { - pending.reject(new Error(typeof envelope.error === "string" ? envelope.error : "Request failed")); + pending.reject( + new Error(typeof envelope.error === "string" ? envelope.error : "Request failed"), + ); return; } @@ -1718,7 +1705,10 @@ export class GatewayWebSocketClient { this.socket.onclose = null; this.socket.onmessage = null; this.socket.onerror = null; - if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) { + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { this.socket.close(); } } @@ -1906,6 +1896,7 @@ export type GatewayWebSocketClientLike = { expectedMtimeMs?: number; expectedContentHash?: string; }): Promise; + readEditableTextFile(workdir: string, path: string): Promise; createDir(workdir: string, path: string): Promise; renamePath(workdir: string, fromPath: string, toPath: string): Promise; deletePath(workdir: string, path: string): Promise; @@ -1915,11 +1906,7 @@ export type GatewayWebSocketClientLike = { ): Promise; readSkillMetadata(path: string): Promise; readSkillText(path: string, offset?: number, length?: number): Promise; - getProviderModels( - type: string, - baseUrl: string, - apiKey: string, - ): Promise; + getProviderModels(type: string, baseUrl: string, apiKey: string): Promise; dispose(): void; }; @@ -2481,20 +2468,14 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } - async renameHistory( - conversationId: string, - title: string, - ): Promise { + async renameHistory(conversationId: string, title: string): Promise { return this.request("history.rename", { conversation_id: conversationId, title, }); } - async pinHistory( - conversationId: string, - isPinned: boolean, - ): Promise { + async pinHistory(conversationId: string, isPinned: boolean): Promise { return this.request("history.pin", { conversation_id: conversationId, is_pinned: isPinned, @@ -2571,10 +2552,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } - async createProjectFolder( - parent: string, - name: string, - ): Promise { + async createProjectFolder(parent: string, name: string): Promise { return this.request("fs.create_project_folder", { parent, name, @@ -2615,6 +2593,13 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } + async readEditableTextFile(workdir: string, path: string): Promise { + return this.request("fs.read_editable_text", { + workdir, + path, + }); + } + async createDir(workdir: string, path: string): Promise { return this.request("fs.create_dir", { workdir, path }); } @@ -2645,11 +2630,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { return this.request("skills.read-metadata", { path }); } - async readSkillText( - path: string, - offset?: number, - length?: number, - ): Promise { + async readSkillText(path: string, offset?: number, length?: number): Promise { return this.request("skills.read-text", { path, offset, @@ -2657,11 +2638,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } - async getProviderModels( - type: string, - baseUrl: string, - apiKey: string, - ): Promise { + async getProviderModels(type: string, baseUrl: string, apiKey: string): Promise { return this.request("provider.models", { type, base_url: baseUrl, @@ -2807,11 +2784,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { private handleMessage(raw: unknown) { const message = raw as SharedWorkerClientMessage; - if ( - !message || - typeof message !== "object" || - message.connection_id !== this.connectionID - ) { + if (!message || typeof message !== "object" || message.connection_id !== this.connectionID) { return; } diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts index 7824bbc0b..7164bb08a 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts @@ -153,7 +153,11 @@ function terminalDetachKey(sessionID: string, projectPathKey: string) { return ""; } -function clearPendingTerminalDetach(client: ManagedClient, sessionID: string, projectPathKey: string) { +function clearPendingTerminalDetach( + client: ManagedClient, + sessionID: string, + projectPathKey: string, +) { const key = terminalDetachKey(sessionID, projectPathKey); if (!key) return; const timer = client.terminalDetachTimers.get(key); @@ -306,7 +310,10 @@ function getManagedClient(token: string) { return managed; } -function connectPort(port: MessagePort, message: Extract) { +function connectPort( + port: MessagePort, + message: Extract, +) { const client = getManagedClient(message.token); clearManagedClientCleanup(client); client.ports.add(port); @@ -385,6 +392,8 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa expectedContentHash: typeof body.expected_content_hash === "string" ? body.expected_content_hash : undefined, }); + case "fs.read_editable_text": + return client.readEditableTextFile(String(body.workdir ?? ""), String(body.path ?? "")); case "fs.create_dir": return client.createDir(String(body.workdir ?? ""), String(body.path ?? "")); case "fs.rename": @@ -416,15 +425,9 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa maxMessages: typeof body.max_messages === "number" ? body.max_messages : undefined, }); case "history.rename": - return client.renameHistory( - String(body.conversation_id ?? ""), - String(body.title ?? ""), - ); + return client.renameHistory(String(body.conversation_id ?? ""), String(body.title ?? "")); case "history.pin": - return client.pinHistory( - String(body.conversation_id ?? ""), - body.is_pinned === true, - ); + return client.pinHistory(String(body.conversation_id ?? ""), body.is_pinned === true); case "history.share.get": return client.getHistoryShare(String(body.conversation_id ?? "")); case "history.share.set": diff --git a/crates/agent-gateway/web/src/lib/monacoNls.ts b/crates/agent-gateway/web/src/lib/monacoNls.ts new file mode 100644 index 000000000..17f5ac61c --- /dev/null +++ b/crates/agent-gateway/web/src/lib/monacoNls.ts @@ -0,0 +1,49 @@ +import { DEFAULT_LOCALE, type Locale } from "@/i18n/config"; + +type MonacoNlsGlobals = typeof globalThis & { + _VSCODE_NLS_LANGUAGE?: string; + _VSCODE_NLS_MESSAGES?: string[]; +}; + +let preferredLocale: Locale = DEFAULT_LOCALE; +let configuredLocale: Locale | null = null; +let localeLocked = false; +let zhCnMessagesPromise: Promise | null = null; + +function clearMonacoNlsGlobals() { + const target = globalThis as MonacoNlsGlobals; + delete target._VSCODE_NLS_LANGUAGE; + delete target._VSCODE_NLS_MESSAGES; +} + +export function setPreferredMonacoNlsLocale(locale: Locale) { + if (localeLocked) return; + preferredLocale = locale; +} + +export async function preparePreferredMonacoNlsLocale() { + const targetLocale = preferredLocale; + if (localeLocked || configuredLocale === targetLocale) return; + + if (targetLocale === "zh-CN") { + zhCnMessagesPromise ??= import("monaco-editor/esm/nls.messages.zh-cn.js").then( + () => undefined, + ); + await zhCnMessagesPromise; + if (localeLocked) return; + if (preferredLocale !== targetLocale) { + clearMonacoNlsGlobals(); + configuredLocale = "en-US"; + return; + } + configuredLocale = "zh-CN"; + return; + } + + clearMonacoNlsGlobals(); + configuredLocale = "en-US"; +} + +export function lockMonacoNlsLocale() { + localeLocked = true; +} diff --git a/crates/agent-gateway/web/src/shims/tauriCore.ts b/crates/agent-gateway/web/src/shims/tauriCore.ts index a030f84e0..5f237fe39 100644 --- a/crates/agent-gateway/web/src/shims/tauriCore.ts +++ b/crates/agent-gateway/web/src/shims/tauriCore.ts @@ -120,15 +120,9 @@ async function readGatewayStatus(): Promise { } } -async function invokeGatewayMemory( - command: string, - args?: Record, -): Promise { +async function invokeGatewayMemory(command: string, args?: Record): Promise { const payloadArgs = - args && - typeof args.args === "object" && - args.args !== null && - !Array.isArray(args.args) + args && typeof args.args === "object" && args.args !== null && !Array.isArray(args.args) ? (args.args as Record) : (args ?? {}); return getGatewayWebSocketClient(loadToken().trim()).memoryManage({ @@ -201,12 +195,8 @@ export async function invoke(command: string, args?: Record) if (!path) { throw new Error("path is required"); } - const maxResults = - typeof args?.max_results === "number" ? args.max_results : undefined; - return (await getGatewayWebSocketClient(loadToken().trim()).listDirs( - path, - maxResults, - )) as T; + const maxResults = typeof args?.max_results === "number" ? args.max_results : undefined; + return (await getGatewayWebSocketClient(loadToken().trim()).listDirs(path, maxResults)) as T; } case "fs_list": return (await getGatewayWebSocketClient(loadToken().trim()).listFiles( @@ -227,6 +217,11 @@ export async function invoke(command: string, args?: Record) expectedContentHash: typeof args?.expected_content_hash === "string" ? args.expected_content_hash : undefined, })) as T; + case "fs_read_editable_text": + return (await getGatewayWebSocketClient(loadToken().trim()).readEditableTextFile( + String(args?.workdir ?? ""), + String(args?.path ?? ""), + )) as T; case "fs_create_dir": return (await getGatewayWebSocketClient(loadToken().trim()).createDir( String(args?.workdir ?? ""), @@ -263,7 +258,10 @@ export async function invoke(command: string, args?: Record) )) as T; case "system_manage_skill": return (await getGatewayWebSocketClient(loadToken().trim()).manageSkill( - (args?.payload && typeof args.payload === "object" ? args.payload : {}) as Record, + (args?.payload && typeof args.payload === "object" ? args.payload : {}) as Record< + string, + unknown + >, )) as T; case "proxy_get_server_info": return { diff --git a/crates/agent-gateway/web/src/styles.css b/crates/agent-gateway/web/src/styles.css index a66286511..e7247f1d9 100644 --- a/crates/agent-gateway/web/src/styles.css +++ b/crates/agent-gateway/web/src/styles.css @@ -509,6 +509,16 @@ html[data-liveagent-webui="gateway"] [role="textbox"]:focus-visible { color: hsl(var(--foreground)); } +.gateway-editor-host { + position: relative; + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; +} + .gateway-main-shell { position: relative; display: flex; @@ -1779,7 +1789,7 @@ html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="cl width: 38px; } - .gateway-shell > .chat-history-sidebar { + .gateway-editor-host > .chat-history-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 45; @@ -1796,16 +1806,16 @@ html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="cl will-change: transform, opacity; } - .gateway-shell > .chat-history-sidebar[data-state="open"] { + .gateway-editor-host > .chat-history-sidebar[data-state="open"] { opacity: 1; transform: translate3d(0, 0, 0); } - .gateway-shell > .chat-history-sidebar[data-state="closed"] { + .gateway-editor-host > .chat-history-sidebar[data-state="closed"] { pointer-events: none; } - .gateway-shell > .chat-history-sidebar > .chat-history-sidebar-inner { + .gateway-editor-host > .chat-history-sidebar > .chat-history-sidebar-inner { width: 100%; min-width: 0; transform: translateZ(0); diff --git a/crates/agent-gateway/web/src/vite-env.d.ts b/crates/agent-gateway/web/src/vite-env.d.ts index c53f345d3..1644a4bf7 100644 --- a/crates/agent-gateway/web/src/vite-env.d.ts +++ b/crates/agent-gateway/web/src/vite-env.d.ts @@ -1,6 +1,8 @@ /// /// +declare module "monaco-editor/esm/nls.messages.zh-cn.js" {} + declare module "~icons/*?raw" { const svg: string; export default svg; diff --git a/crates/agent-gui/package.json b/crates/agent-gui/package.json index e797ae7fd..c5b53e434 100644 --- a/crates/agent-gui/package.json +++ b/crates/agent-gui/package.json @@ -37,6 +37,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.45", + "monaco-editor": "^0.55.1", "react": "^19.2.4", "react-dom": "^19.2.4", "remark-breaks": "^4.0.0", diff --git a/crates/agent-gui/pnpm-lock.yaml b/crates/agent-gui/pnpm-lock.yaml index 0a103c1d6..5525148b7 100644 --- a/crates/agent-gui/pnpm-lock.yaml +++ b/crates/agent-gui/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: katex: specifier: ^0.16.45 version: 0.16.45 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 react: specifier: ^19.2.4 version: 19.2.4 @@ -1728,6 +1731,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} @@ -2221,6 +2227,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -2424,6 +2435,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4749,6 +4763,10 @@ snapshots: diff@8.0.4: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -5310,6 +5328,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@16.4.2: {} marked@17.0.5: {} @@ -5760,6 +5780,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/crates/agent-gui/src-tauri/src/commands/fs.rs b/crates/agent-gui/src-tauri/src/commands/fs.rs index 77cb394ad..3136152a9 100644 --- a/crates/agent-gui/src-tauri/src/commands/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/fs.rs @@ -18,6 +18,7 @@ use zip::ZipArchive; use crate::runtime::platform::expand_tilde_path; const READ_MAX_TEXT_BYTES: usize = 200 * 1024; // 200KB +const EDITABLE_TEXT_MAX_BYTES: usize = 3 * 1024 * 1024; // 3MB const READ_MAX_IMAGE_BYTES: usize = 25 * 1024 * 1024; // 25MB const IMAGE_SOURCE_HTTP_TIMEOUT_SECS: u64 = 20; const DEFAULT_READ_LIMIT_LINES: usize = 200; @@ -524,6 +525,25 @@ fn is_zip_archive_file(path: &Path) -> bool { matches!(extension_lower(path).as_deref(), Some("zip")) } +fn editable_text_unsupported_reason(path: &Path) -> Option<&'static str> { + if infer_image_mime(path).is_some() { + return Some("Image files are not supported in the code editor"); + } + if is_pdf_file(path) { + return Some("PDF files are not supported in the code editor"); + } + if is_notebook_file(path) { + return Some("Notebook files are not supported in the code editor"); + } + if is_word_file(path) || is_spreadsheet_file(path) { + return Some("Office documents are not supported in the code editor"); + } + if is_archive_file(path) { + return Some("Archive files are not supported in the code editor"); + } + None +} + fn office_mime_type(path: &Path) -> Option<&'static str> { match extension_lower(path).as_deref() { Some("docx") => { @@ -1963,6 +1983,75 @@ pub async fn fs_read_text( .await } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadEditableTextResponse { + pub path: String, + pub content: String, + pub mtime_ms: u64, + pub content_hash: String, + pub size_bytes: usize, + pub total_lines: usize, +} + +pub(crate) fn fs_read_editable_text_sync( + workdir: String, + path: String, +) -> Result { + let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; + let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; + let logical_path = logical_rel_path(&rel); + let target = wd.join(&rel); + let target = resolve_existing_file_target(&wd, &target, "Read.path")?; + if let Some(reason) = editable_text_unsupported_reason(&target) { + return Err(FsError::Other(reason.to_string()).to_string()); + } + + let md = fs::metadata(&target).map_err(|e| e.to_string())?; + let size_bytes = usize::try_from(md.len()).unwrap_or(usize::MAX); + if size_bytes > EDITABLE_TEXT_MAX_BYTES { + return Err(FsError::Other(format!( + "File is too large to edit ({size_bytes} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)" + )) + .to_string()); + } + + let bytes = fs::read(&target).map_err(|e| e.to_string())?; + if bytes.len() > EDITABLE_TEXT_MAX_BYTES { + return Err(FsError::Other(format!( + "File is too large to edit ({} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)", + bytes.len() + )) + .to_string()); + } + + let mtime_ms = metadata_mtime_ms(&md); + let content_hash = hash_bytes(&bytes); + let content = String::from_utf8(bytes) + .map_err(|_| FsError::Other("File is not valid UTF-8 text".to_string()).to_string())?; + let total_lines = count_text_lines(&content); + + Ok(ReadEditableTextResponse { + path: logical_path, + content, + mtime_ms, + content_hash, + size_bytes, + total_lines, + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_read_editable_text( + workdir: String, + path: String, +) -> Result { + run_blocking("fs_read_editable_text", move || { + fs_read_editable_text_sync(workdir, path) + }) + .await +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteTextResponse { @@ -3395,6 +3484,93 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + #[test] + fn read_editable_text_returns_raw_utf8_with_version_metadata() { + let workdir = unique_test_workdir("read-editable"); + fs::create_dir_all(workdir.join("src")).expect("create workdir"); + fs::write(workdir.join("src/main.rs"), "fn main() {}\n").expect("write file"); + + let response = + fs_read_editable_text_sync(workdir.display().to_string(), "src/main.rs".to_string()) + .expect("editable text should read"); + + assert_eq!(response.path, "src/main.rs"); + assert_eq!(response.content, "fn main() {}\n"); + assert_eq!(response.size_bytes, "fn main() {}\n".len()); + assert_eq!(response.total_lines, 1); + assert!( + !response.content.contains("\tfn main"), + "content must not be numbered" + ); + assert_ne!(response.mtime_ms, 0); + assert_eq!( + response.content_hash, + hash_bytes("fn main() {}\n".as_bytes()) + ); + + let _ = fs::remove_dir_all(workdir); + } + + #[test] + fn read_editable_text_rejects_invalid_targets_and_non_utf8() { + let workdir = unique_test_workdir("read-editable-invalid"); + fs::create_dir_all(workdir.join("src")).expect("create workdir"); + fs::write(workdir.join("src/binary.bin"), [0xff, 0xfe, 0xfd]).expect("write binary"); + fs::write(workdir.join("src/notebook.ipynb"), "{}\n").expect("write notebook"); + fs::write(workdir.join("src/readme.pdf"), "%PDF-1.7\n").expect("write pdf"); + fs::write( + workdir.join("src/too-large.txt"), + vec![b'a'; EDITABLE_TEXT_MAX_BYTES + 1], + ) + .expect("write large file"); + + let dir_error = + fs_read_editable_text_sync(workdir.display().to_string(), "src".to_string()) + .expect_err("directory should fail"); + assert!( + dir_error.contains("regular file"), + "unexpected error: {dir_error}" + ); + + let utf8_error = + fs_read_editable_text_sync(workdir.display().to_string(), "src/binary.bin".to_string()) + .expect_err("invalid UTF-8 should fail"); + assert!( + utf8_error.contains("UTF-8"), + "unexpected error: {utf8_error}" + ); + + for (path, expected) in [ + ("src/notebook.ipynb", "Notebook"), + ("src/readme.pdf", "PDF"), + ] { + let error = fs_read_editable_text_sync(workdir.display().to_string(), path.to_string()) + .expect_err("unsupported preview file should fail"); + assert!( + error.contains(expected), + "unexpected error for {path}: {error}" + ); + } + + let large_error = fs_read_editable_text_sync( + workdir.display().to_string(), + "src/too-large.txt".to_string(), + ) + .expect_err("large file should fail"); + assert!( + large_error.contains("too large"), + "unexpected error: {large_error}" + ); + + for path in ["", "/tmp/liveagent-outside", "../outside"] { + let error = fs_read_editable_text_sync(workdir.display().to_string(), path.to_string()) + .expect_err("invalid path should fail"); + assert!(!error.trim().is_empty(), "expected error for {path:?}"); + } + + let _ = fs::remove_dir_all(workdir); + } + #[test] fn create_dir_creates_project_directory_and_rejects_invalid_targets() { let workdir = unique_test_workdir("create-dir"); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 683a51626..5f2a15e2c 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -60,6 +60,7 @@ macro_rules! app_invoke_handler { commands::subagent_history::subagent_run_prune, // File system commands::fs::fs_read_text, + commands::fs::fs_read_editable_text, commands::fs::fs_read_image_source, commands::fs::fs_write_text, commands::fs::fs_edit_text, diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index ac171a216..87c0d532d 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -984,6 +984,21 @@ impl GatewayController { Err(error) => self.send_error_response(request_id, 500, error).await, } } + Some(proto::gateway_envelope::Payload::FsReadEditableText(request)) => { + match gateway_bridge::handle_fs_read_editable_text(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsReadEditableTextResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } Some(proto::gateway_envelope::Payload::FsWriteText(request)) => { match gateway_bridge::handle_fs_write_text(request).await { Ok(response) => { diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index 282526353..2a2148845 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -6,8 +6,8 @@ use serde_json::{json, Value}; use crate::commands::{ chat_history, fs::{ - fs_create_dir_sync, fs_delete_sync, fs_list_sync, fs_mention_list_sync, fs_rename_sync, - fs_write_text_sync, + fs_create_dir_sync, fs_delete_sync, fs_list_sync, fs_mention_list_sync, + fs_read_editable_text_sync, fs_rename_sync, fs_write_text_sync, }, git::git_gateway_action_sync, settings::{load_providers, open_db}, @@ -533,6 +533,24 @@ pub async fn handle_fs_list( }) } +pub async fn handle_fs_read_editable_text( + request: proto::FsReadEditableTextRequest, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + fs_read_editable_text_sync(request.workdir, request.path) + }) + .await + .map_err(|e| format!("gateway fs read editable text join failed: {e}"))? + .map(|response| proto::FsReadEditableTextResponse { + path: response.path, + content: response.content, + mtime_ms: response.mtime_ms, + content_hash: response.content_hash, + size_bytes: u64::try_from(response.size_bytes).unwrap_or(u64::MAX), + total_lines: u64::try_from(response.total_lines).unwrap_or(u64::MAX), + }) +} + pub async fn handle_fs_write_text( request: proto::FsWriteTextRequest, ) -> Result { diff --git a/crates/agent-gui/src/components/icons.tsx b/crates/agent-gui/src/components/icons.tsx index 759425f86..9e5d130f1 100644 --- a/crates/agent-gui/src/components/icons.tsx +++ b/crates/agent-gui/src/components/icons.tsx @@ -67,7 +67,10 @@ import PlaySource from "~icons/lucide/play"; import PlugSource from "~icons/lucide/plug"; import PlusSource from "~icons/lucide/plus"; import RadioSource from "~icons/lucide/radio"; +import Redo2Source from "~icons/lucide/redo-2"; import RefreshCwSource from "~icons/lucide/refresh-cw"; +import ReplaceSource from "~icons/lucide/replace"; +import SaveSource from "~icons/lucide/save"; import ScanTextSource from "~icons/lucide/scan-text"; import ScissorsSource from "~icons/lucide/scissors"; import ScrollTextSource from "~icons/lucide/scroll-text"; @@ -85,7 +88,9 @@ import SunSource from "~icons/lucide/sun"; import TagSource from "~icons/lucide/tag"; import TerminalSource from "~icons/lucide/terminal"; import Trash2Source from "~icons/lucide/trash-2"; +import TextSelectSource from "~icons/lucide/text-select"; import AlertTriangleSource from "~icons/lucide/triangle-alert"; +import Undo2Source from "~icons/lucide/undo-2"; import UploadSource from "~icons/lucide/upload"; import WifiSource from "~icons/lucide/wifi"; import WifiOffSource from "~icons/lucide/wifi-off"; @@ -191,7 +196,10 @@ export const Play = createIcon(PlaySource); export const Plug = createIcon(PlugSource); export const Plus = createIcon(PlusSource); export const Radio = createIcon(RadioSource); +export const Redo2 = createIcon(Redo2Source); export const RefreshCw = createIcon(RefreshCwSource); +export const Replace = createIcon(ReplaceSource); +export const Save = createIcon(SaveSource); export const ScanText = createIcon(ScanTextSource); export const ScrollText = createIcon(ScrollTextSource); export const Scissors = createIcon(ScissorsSource); @@ -208,7 +216,9 @@ export const SquarePen = createIcon(SquarePenSource); export const Sun = createIcon(SunSource); export const Tag = createIcon(TagSource); export const Terminal = createIcon(TerminalSource); +export const TextSelect = createIcon(TextSelectSource); export const Trash2 = createIcon(Trash2Source); +export const Undo2 = createIcon(Undo2Source); export const Upload = createIcon(UploadSource); export const Wifi = createIcon(WifiSource); export const WifiOff = createIcon(WifiOffSource); diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx index 1bbb44fa9..6d82a2c4e 100644 --- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx @@ -605,7 +605,10 @@ const DIFF_SELECTION_CONTEXT_MENU_MARGIN = 12; const GIT_HISTORY_PAGE_SIZE = 50; const GIT_HISTORY_LOAD_MORE_SCROLL_THRESHOLD_PX = 96; const CHANGE_CONTEXT_MENU_ITEM_CLASS = - "flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; + "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-45"; +const CONTEXT_MENU_CONTAINER_CLASS = + "editor-context-menu select-none overflow-hidden rounded-xl border border-border/60 bg-popover/80 p-1 text-xs text-popover-foreground shadow-2xl ring-1 ring-black/[0.03] backdrop-blur-xl dark:ring-white/[0.06]"; +const CONTEXT_MENU_SEPARATOR_CLASS = "mx-1 my-1 h-px bg-border/60"; const GIT_REVIEW_POLL_INTERVAL_MS = 1500; type GitRefreshOptions = { @@ -1840,7 +1843,7 @@ function DiffContent(props: {
{ writeTextToClipboard(selectionContextMenu.selectedText); closeSelectionContextMenu(); @@ -4976,7 +4979,7 @@ export function GitReviewPanel(props: { (historyContextMenu.kind === "commit" || historyContextFile) ? (
event.stopPropagation()} onContextMenu={(event) => { @@ -5027,7 +5030,7 @@ export function GitReviewPanel(props: { {t("projectTools.gitReview.openOnGithub")} -
+
-
+
-
+
-
+
+
+ + ) : null} -
+
-
+
-
{t("projectTools.newTerminal")}
-
{t("projectTools.terminalDescription")}
+
+ {t("projectTools.newTerminal")} +
+
+ {t("projectTools.terminalDescription")} +
-
{t("projectTools.newFileTree")}
-
{t("projectTools.fileTreeDescription")}
+
+ {t("projectTools.newFileTree")} +
+
+ {t("projectTools.fileTreeDescription")} +
-
{t("projectTools.newGitReview")}
-
{t("projectTools.gitReviewDescription")}
+
+ {t("projectTools.newGitReview")} +
+
+ {t("projectTools.gitReviewDescription")} +
@@ -1636,9 +1650,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { {t("projectTools.loading")}
) : null} - {error ? ( -
{error}
- ) : null} + {error ?
{error}
: null}
) : ( <> @@ -1658,6 +1670,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { onInitializedChange={setFileTreeInitialized} onSyncStateChange={onFileTreeStateChange} onInsertFileMention={onInsertFileMention} + onOpenEditableFile={onOpenEditableFile} />
) : null} @@ -1704,16 +1717,24 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
-
{t("projectTools.newTerminal")}
+
+ {t("projectTools.newTerminal")} +
{terminalDisabledMessage ? (
{terminalDisabledMessage}
) : ( -
{t("projectTools.terminalDescription")}
+
+ {t("projectTools.terminalDescription")} +
)}
- {loading ? ( diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx new file mode 100644 index 000000000..bceb7180e --- /dev/null +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -0,0 +1,1077 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import * as monaco from "monaco-editor"; +import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; +import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; +import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; +import { useLocale } from "../../i18n"; +import { cn } from "../../lib/shared/utils"; +import { + AlertTriangle, + ClipboardPaste, + Copy, + FilePenLine, + Loader2, + Redo2, + RefreshCw, + Replace, + Save, + Scissors, + Search, + TextSelect, + Undo2, + X, +} from "../icons"; +import type { IconComponent } from "../icons"; +import { MacOsTitleBarSpacer } from "../MacOsTitleBarSpacer"; + +type MonacoEnvironmentGlobal = typeof globalThis & { + MonacoEnvironment?: { + getWorker: (workerId: string, label: string) => Worker; + }; +}; + +const monacoGlobal = globalThis as MonacoEnvironmentGlobal; + +if (!monacoGlobal.MonacoEnvironment) { + monacoGlobal.MonacoEnvironment = { + getWorker(_workerId, label) { + if (label === "json") return new JsonWorker(); + if (label === "css" || label === "scss" || label === "less") return new CssWorker(); + if (label === "html" || label === "handlebars" || label === "razor") { + return new HtmlWorker(); + } + if (label === "typescript" || label === "javascript") return new TsWorker(); + return new EditorWorker(); + }, + }; +} + +export type WorkspaceCodeEditorOpenRequest = { + id: number; + projectPathKey: string; + workdir: string; + path: string; +}; + +type ReadEditableTextResponse = { + path: string; + content: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; +}; + +type WriteTextResponse = { + path: string; + mtimeMs: number; + contentHash: string; + totalLines: number; +}; + +type EditorTabStatus = "ready" | "saving" | "conflict"; + +type EditorTab = { + key: string; + projectPathKey: string; + workdir: string; + path: string; + content: string; + savedContent: string; + mtimeMs: number; + contentHash: string; + sizeBytes: number; + totalLines: number; + language: string; + status: EditorTabStatus; + error: string | null; +}; + +type PendingDialog = + | { kind: "closeOverlay" } + | { kind: "closeTab"; tabKey: string } + | { kind: "reloadTab"; tabKey: string }; + +type EditorContextMenuState = { + x: number; + y: number; +}; + +const EDITOR_OVERLAY_ANIMATION_MS = 180; +const EDITOR_CONTEXT_MENU_WIDTH = 220; +const EDITOR_CONTEXT_MENU_HEIGHT = 300; + +type WorkspaceCodeEditorOverlayProps = { + openRequest: WorkspaceCodeEditorOpenRequest | null; + closeRequestId?: number; + theme: "light" | "dark"; + onClose: () => void; +}; + +function editorTabKey(projectPathKey: string, path: string) { + return `${projectPathKey}\u0000${path}`; +} + +function basename(path: string) { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} + +function dirname(path: string) { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index > 0 ? normalized.slice(0, index) : ""; +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function languageForPath(path: string) { + const name = basename(path).toLowerCase(); + if (name === "dockerfile") return "dockerfile"; + if (name === "makefile") return "makefile"; + if (name === "cargo.lock") return "toml"; + if (name.endsWith(".d.ts")) return "typescript"; + + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + switch (ext) { + case "js": + case "jsx": + case "mjs": + case "cjs": + return "javascript"; + case "ts": + case "tsx": + return "typescript"; + case "json": + case "jsonc": + return "json"; + case "css": + return "css"; + case "scss": + case "sass": + return "scss"; + case "less": + return "less"; + case "html": + case "htm": + return "html"; + case "md": + case "mdx": + return "markdown"; + case "rs": + return "rust"; + case "go": + return "go"; + case "py": + return "python"; + case "java": + return "java"; + case "kt": + case "kts": + return "kotlin"; + case "c": + case "h": + return "c"; + case "cc": + case "cpp": + case "cxx": + case "hpp": + return "cpp"; + case "cs": + return "csharp"; + case "php": + return "php"; + case "rb": + return "ruby"; + case "swift": + return "swift"; + case "sh": + case "bash": + case "zsh": + return "shell"; + case "yml": + case "yaml": + return "yaml"; + case "toml": + return "toml"; + case "xml": + case "svg": + return "xml"; + case "sql": + return "sql"; + case "graphql": + case "gql": + return "graphql"; + default: + return "plaintext"; + } +} + +function isVersionConflict(error: unknown) { + const message = error instanceof Error ? error.message : String(error ?? ""); + return message.includes("File changed since the last full Read"); +} + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) return error.message; + const text = String(error ?? "").trim(); + return text || fallback; +} + +function editorModelUri(tabKey: string) { + const bytes = new TextEncoder().encode(tabKey); + let hexKey = ""; + for (const byte of bytes) { + hexKey += byte.toString(16).padStart(2, "0"); + } + return monaco.Uri.from({ + scheme: "liveagent-editor", + authority: "model", + path: `/${hexKey}`, + }); +} + +function isMacLikePlatform() { + if (typeof navigator === "undefined") return false; + const platform = `${navigator.userAgent} ${navigator.platform}`; + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +function getEditorContextMenuShortcuts() { + const isMac = isMacLikePlatform(); + return { + undo: isMac ? "⌘Z" : "Ctrl+Z", + redo: isMac ? "⌘⇧Z" : "Ctrl+Y", + cut: isMac ? "⌘X" : "Ctrl+X", + copy: isMac ? "⌘C" : "Ctrl+C", + paste: isMac ? "⌘V" : "Ctrl+V", + selectAll: isMac ? "⌘A" : "Ctrl+A", + find: isMac ? "⌘F" : "Ctrl+F", + replace: isMac ? "⌥⌘F" : "Ctrl+H", + } as const; +} + +export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProps) { + const { openRequest, closeRequestId, theme, onClose } = props; + const { t } = useLocale(); + const contextMenuShortcuts = useMemo(() => getEditorContextMenuShortcuts(), []); + const overlayRef = useRef(null); + const containerRef = useRef(null); + const editorRef = useRef(null); + const modelsRef = useRef(new Map()); + const viewStatesRef = useRef(new Map()); + const editorModelKeyRef = useRef(""); + const activeKeyRef = useRef(""); + const openRequestIdRef = useRef(null); + const closeRequestIdRef = useRef(null); + const openAnimationFrameRef = useRef(null); + const closeAnimationTimeoutRef = useRef(null); + const initialThemeRef = useRef(theme); + const [tabs, setTabs] = useState([]); + const [activeKey, setActiveKey] = useState(""); + const [openingPaths, setOpeningPaths] = useState([]); + const [globalError, setGlobalError] = useState(null); + const [pendingDialog, setPendingDialog] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.key === activeKey) ?? tabs[0] ?? null, + [activeKey, tabs], + ); + const dirtyTabs = useMemo(() => tabs.filter((tab) => tab.content !== tab.savedContent), [tabs]); + const hasDirtyTabs = dirtyTabs.length > 0; + const isOpening = openingPaths.length > 0; + + useEffect(() => { + openAnimationFrameRef.current = window.requestAnimationFrame(() => { + openAnimationFrameRef.current = null; + setIsVisible(true); + }); + return () => { + if (openAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(openAnimationFrameRef.current); + } + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + } + }; + }, []); + + const cancelPendingClose = useCallback(() => { + if (closeAnimationTimeoutRef.current === null) return; + window.clearTimeout(closeAnimationTimeoutRef.current); + closeAnimationTimeoutRef.current = null; + setIsVisible(true); + }, []); + + const finishClose = useCallback(() => { + if (closeAnimationTimeoutRef.current !== null) return; + setIsVisible(false); + closeAnimationTimeoutRef.current = window.setTimeout(() => { + closeAnimationTimeoutRef.current = null; + onClose(); + }, EDITOR_OVERLAY_ANIMATION_MS); + }, [onClose]); + + const updateTab = useCallback((tabKey: string, updater: (tab: EditorTab) => EditorTab) => { + setTabs((current) => current.map((tab) => (tab.key === tabKey ? updater(tab) : tab))); + }, []); + + const disposeModel = useCallback((tabKey: string) => { + const model = modelsRef.current.get(tabKey); + if (model) { + if (editorRef.current?.getModel() === model) { + editorRef.current.setModel(null); + } + model.dispose(); + modelsRef.current.delete(tabKey); + } + if (editorModelKeyRef.current === tabKey) { + editorModelKeyRef.current = ""; + } + viewStatesRef.current.delete(tabKey); + }, []); + + const saveTab = useCallback( + async (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab || tab.content === tab.savedContent || tab.status === "saving") return true; + if (tab.status === "conflict") { + const message = tab.error ?? t("workspaceEditor.conflictMessage"); + setGlobalError(message); + return false; + } + + const contentToSave = tab.content; + updateTab(tabKey, (current) => ({ ...current, status: "saving", error: null })); + try { + const response = await invoke("fs_write_text", { + workdir: tab.workdir, + path: tab.path, + content: contentToSave, + mode: "rewrite", + expected_mtime_ms: tab.mtimeMs, + expected_content_hash: tab.contentHash, + }); + updateTab(tabKey, (current) => ({ + ...current, + savedContent: contentToSave, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + totalLines: current.content === contentToSave ? response.totalLines : current.totalLines, + sizeBytes: new TextEncoder().encode(current.content).length, + status: "ready", + error: null, + })); + setGlobalError(null); + return true; + } catch (error) { + const conflict = isVersionConflict(error); + const message = conflict + ? t("workspaceEditor.conflictMessage") + : toMessage(error, t("workspaceEditor.saveFailed")); + updateTab(tabKey, (current) => ({ + ...current, + status: conflict ? "conflict" : "ready", + error: message, + })); + setGlobalError(message); + return false; + } + }, + [t, tabs, updateTab], + ); + + const readTab = useCallback( + async (request: WorkspaceCodeEditorOpenRequest) => { + const key = editorTabKey(request.projectPathKey, request.path); + const existing = tabs.find((tab) => tab.key === key); + if (existing) { + setActiveKey(key); + setGlobalError(null); + return; + } + + setOpeningPaths((current) => [ + ...current.filter((item) => item !== request.path), + request.path, + ]); + setGlobalError(null); + try { + const response = await invoke("fs_read_editable_text", { + workdir: request.workdir, + path: request.path, + }); + const nextTab: EditorTab = { + key, + projectPathKey: request.projectPathKey, + workdir: request.workdir, + path: response.path, + content: response.content, + savedContent: response.content, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + sizeBytes: response.sizeBytes, + totalLines: response.totalLines, + language: languageForPath(response.path), + status: "ready", + error: null, + }; + setTabs((current) => { + if (current.some((tab) => tab.key === key)) return current; + return [...current, nextTab]; + }); + setActiveKey(key); + } catch (error) { + setGlobalError(toMessage(error, t("workspaceEditor.openFailed"))); + } finally { + setOpeningPaths((current) => current.filter((item) => item !== request.path)); + } + }, + [t, tabs], + ); + + const reloadTab = useCallback( + async (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return false; + setOpeningPaths((current) => [...current.filter((item) => item !== tab.path), tab.path]); + setGlobalError(null); + try { + const response = await invoke("fs_read_editable_text", { + workdir: tab.workdir, + path: tab.path, + }); + const model = modelsRef.current.get(tabKey); + if (model && model.getValue() !== response.content) { + model.setValue(response.content); + } + updateTab(tabKey, (current) => ({ + ...current, + path: response.path, + content: response.content, + savedContent: response.content, + mtimeMs: response.mtimeMs, + contentHash: response.contentHash, + sizeBytes: response.sizeBytes, + totalLines: response.totalLines, + language: languageForPath(response.path), + status: "ready", + error: null, + })); + return true; + } catch (error) { + const message = toMessage(error, t("workspaceEditor.reloadFailed")); + updateTab(tabKey, (current) => ({ ...current, error: message })); + setGlobalError(message); + return false; + } finally { + setOpeningPaths((current) => current.filter((item) => item !== tab.path)); + } + }, + [t, tabs, updateTab], + ); + + const closeTabNow = useCallback( + (tabKey: string) => { + disposeModel(tabKey); + setTabs((current) => { + const index = current.findIndex((tab) => tab.key === tabKey); + if (index < 0) return current; + const next = current.filter((tab) => tab.key !== tabKey); + setActiveKey((currentActive) => { + if (currentActive !== tabKey) return currentActive; + return next[Math.min(index, next.length - 1)]?.key ?? ""; + }); + if (next.length === 0) { + window.requestAnimationFrame(finishClose); + } + return next; + }); + }, + [disposeModel, finishClose], + ); + + const requestCloseTab = useCallback( + (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return; + if (tab.content !== tab.savedContent) { + setPendingDialog({ kind: "closeTab", tabKey }); + return; + } + closeTabNow(tabKey); + }, + [closeTabNow, tabs], + ); + + const requestReloadTab = useCallback( + (tabKey: string) => { + const tab = tabs.find((item) => item.key === tabKey); + if (!tab) return; + if (tab.status !== "conflict" && tab.content !== tab.savedContent) { + setPendingDialog({ kind: "reloadTab", tabKey }); + return; + } + void reloadTab(tabKey); + }, + [reloadTab, tabs], + ); + + const requestCloseOverlay = useCallback(() => { + if (hasDirtyTabs) { + setPendingDialog({ kind: "closeOverlay" }); + return; + } + finishClose(); + }, [finishClose, hasDirtyTabs]); + + useEffect(() => { + if (closeRequestId == null) return; + if (closeRequestIdRef.current == null) { + closeRequestIdRef.current = closeRequestId; + return; + } + if (closeRequestIdRef.current === closeRequestId) return; + closeRequestIdRef.current = closeRequestId; + requestCloseOverlay(); + }, [closeRequestId, requestCloseOverlay]); + + const discardDialogTarget = useCallback(() => { + const dialog = pendingDialog; + setPendingDialog(null); + if (!dialog) return; + if (dialog.kind === "closeOverlay") { + finishClose(); + return; + } + if (dialog.kind === "closeTab") { + closeTabNow(dialog.tabKey); + return; + } + void reloadTab(dialog.tabKey); + }, [closeTabNow, finishClose, pendingDialog, reloadTab]); + + const saveDialogTarget = useCallback(() => { + const dialog = pendingDialog; + if (!dialog) return; + void (async () => { + if (dialog.kind === "closeOverlay") { + for (const tab of dirtyTabs) { + const saved = await saveTab(tab.key); + if (!saved) return; + } + setPendingDialog(null); + finishClose(); + return; + } + const saved = await saveTab(dialog.tabKey); + if (!saved) return; + setPendingDialog(null); + if (dialog.kind === "closeTab") { + closeTabNow(dialog.tabKey); + } else { + void reloadTab(dialog.tabKey); + } + })(); + }, [closeTabNow, dirtyTabs, finishClose, pendingDialog, reloadTab, saveTab]); + + const showFind = useCallback(() => { + editorRef.current?.focus(); + editorRef.current?.trigger("toolbar", "actions.find", null); + }, []); + + const showReplace = useCallback(() => { + editorRef.current?.focus(); + editorRef.current?.trigger("toolbar", "editor.action.startFindReplaceAction", null); + }, []); + + const runEditorCommand = useCallback((commandId: string) => { + setContextMenu(null); + const editor = editorRef.current; + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", commandId, null); + }, []); + + const openEditorContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!activeTab || pendingDialog) return; + event.preventDefault(); + event.stopPropagation(); + editorRef.current?.focus(); + + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect) return; + const maxX = Math.max(8, rect.width - EDITOR_CONTEXT_MENU_WIDTH - 8); + const maxY = Math.max(8, rect.height - EDITOR_CONTEXT_MENU_HEIGHT - 8); + setContextMenu({ + x: Math.min(Math.max(event.clientX - rect.left, 8), maxX), + y: Math.min(Math.max(event.clientY - rect.top, 8), maxY), + }); + }, + [activeTab, pendingDialog], + ); + + useEffect(() => { + if (!openRequest || openRequestIdRef.current === openRequest.id) return; + openRequestIdRef.current = openRequest.id; + cancelPendingClose(); + void readTab(openRequest); + }, [cancelPendingClose, openRequest, readTab]); + + useEffect(() => { + activeKeyRef.current = activeTab?.key ?? ""; + }, [activeTab?.key]); + + useEffect(() => { + const container = containerRef.current; + if (!container || editorRef.current) return; + const editor = monaco.editor.create(container, { + automaticLayout: true, + fontSize: 13, + fontLigatures: true, + minimap: { enabled: true }, + model: null, + contextmenu: false, + scrollBeyondLastLine: false, + smoothScrolling: true, + tabSize: 2, + theme: initialThemeRef.current === "dark" ? "vs-dark" : "vs", + }); + editorRef.current = editor; + return () => { + editor.dispose(); + editorRef.current = null; + for (const model of modelsRef.current.values()) { + model.dispose(); + } + modelsRef.current.clear(); + viewStatesRef.current.clear(); + }; + }, []); + + useEffect(() => { + monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "vs"); + }, [theme]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor || !activeTab) { + editorRef.current?.setModel(null); + return; + } + + const previousKey = editorModelKeyRef.current; + if (previousKey && previousKey !== activeTab.key) { + viewStatesRef.current.set(previousKey, editor.saveViewState()); + } + + let model = modelsRef.current.get(activeTab.key); + if (!model) { + model = monaco.editor.createModel( + activeTab.content, + activeTab.language, + editorModelUri(activeTab.key), + ); + model.onDidChangeContent(() => { + const value = model?.getValue() ?? ""; + const lineCount = model?.getLineCount() ?? 0; + setTabs((current) => + current.map((tab) => + tab.key === activeTab.key + ? { ...tab, content: value, totalLines: lineCount, error: null } + : tab, + ), + ); + }); + modelsRef.current.set(activeTab.key, model); + } + if (model.getLanguageId() !== activeTab.language) { + monaco.editor.setModelLanguage(model, activeTab.language); + } + if (editor.getModel() !== model) { + editor.setModel(model); + const viewState = viewStatesRef.current.get(activeTab.key); + if (viewState) { + editor.restoreViewState(viewState); + } + editor.focus(); + } + editorModelKeyRef.current = activeTab.key; + }, [activeTab]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenu(null); + } + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return; + const currentKey = activeKeyRef.current; + if (!currentKey) return; + event.preventDefault(); + void saveTab(currentKey); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [saveTab]); + + useEffect(() => { + if (!contextMenu) return; + const closeContextMenu = () => setContextMenu(null); + window.addEventListener("click", closeContextMenu); + window.addEventListener("blur", closeContextMenu); + window.addEventListener("resize", closeContextMenu); + return () => { + window.removeEventListener("click", closeContextMenu); + window.removeEventListener("blur", closeContextMenu); + window.removeEventListener("resize", closeContextMenu); + }; + }, [contextMenu]); + + const dialogTitle = + pendingDialog?.kind === "closeOverlay" + ? t("workspaceEditor.closeDirtyTitle") + : pendingDialog?.kind === "reloadTab" + ? t("workspaceEditor.reloadDirtyTitle") + : t("workspaceEditor.closeTabDirtyTitle"); + const dialogDescription = + pendingDialog?.kind === "closeOverlay" + ? t("workspaceEditor.closeDirtyDescription") + : pendingDialog?.kind === "reloadTab" + ? t("workspaceEditor.reloadDirtyDescription") + : t("workspaceEditor.closeTabDirtyDescription"); + + return ( +
+ +
+ +
+
+ {t("workspaceEditor.title")} +
+
+ {activeTab ? activeTab.path : t("workspaceEditor.empty")} +
+
+
+ activeTab && void saveTab(activeTab.key)} + > + {activeTab?.status === "saving" ? ( + + ) : ( + + )} + + + + + + + + activeTab && requestReloadTab(activeTab.key)} + > + + + + + +
+
+ +
+ {tabs.map((tab) => { + const dirty = tab.content !== tab.savedContent; + return ( + + ); + })} +
+ + {globalError || activeTab?.error ? ( +
+ +
{activeTab?.error ?? globalError}
+ {activeTab?.status === "conflict" ? ( + + ) : null} +
+ ) : null} + +
+
+ {!activeTab ? ( +
+ {isOpening ? ( + + ) : ( + + )} +
{isOpening ? t("workspaceEditor.opening") : t("workspaceEditor.emptyHint")}
+ {globalError ? ( +
+ {globalError} +
+ ) : null} +
+ ) : null} +
+ + {contextMenu ? ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + onMouseDown={(event) => event.preventDefault()} + > + runEditorCommand("undo")} + /> + runEditorCommand("redo")} + /> + + runEditorCommand("editor.action.clipboardCutAction")} + /> + runEditorCommand("editor.action.clipboardCopyAction")} + /> + runEditorCommand("editor.action.clipboardPasteAction")} + /> + + runEditorCommand("editor.action.selectAll")} + /> + + { + setContextMenu(null); + showFind(); + }} + /> + { + setContextMenu(null); + showReplace(); + }} + /> +
+ ) : null} + +
+ + {activeTab ? dirname(activeTab.path) || "/" : t("workspaceEditor.noFile")} + + + {activeTab + ? `${activeTab.language} · ${activeTab.totalLines} ${t("workspaceEditor.lines")} · ${formatBytes(activeTab.sizeBytes)}` + : ""} + + {activeTab?.content !== activeTab?.savedContent ? ( + {t("workspaceEditor.unsaved")} + ) : null} +
+ + {pendingDialog ? ( +
+
+
{dialogTitle}
+
{dialogDescription}
+
+ + + +
+
+
+ ) : null} +
+ ); +} + +function ContextMenuItem(props: { + icon?: IconComponent; + label: string; + shortcut?: string; + onClick: () => void; +}) { + const Icon = props.icon; + return ( + + ); +} + +function ContextMenuSeparator() { + return
; +} + +function IconButton(props: { + label: string; + disabled?: boolean; + children: ReactNode; + onClick: () => void; +}) { + const { label, disabled = false, children, onClick } = props; + return ( + + ); +} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 54efb7487..d56d3e134 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -423,11 +423,48 @@ export const translations: Record> = { "projectTools.fileTree.resultsTruncated": "结果已截断", "projectTools.fileTree.newFile": "新建文件", "projectTools.fileTree.newFolder": "新建文件夹", + "projectTools.fileTree.openFile": "打开文件", "projectTools.fileTree.rename": "重命名", "projectTools.fileTree.delete": "删除", "projectTools.fileTree.copyPath": "复制路径", "projectTools.fileTree.copiedPath": "已复制路径", "projectTools.fileTree.insertReference": "插入引用", + "workspaceEditor.loading": "正在加载编辑器...", + "workspaceEditor.title": "代码编辑器", + "workspaceEditor.empty": "未打开文件", + "workspaceEditor.emptyHint": "从右侧文件树双击可编辑文件", + "workspaceEditor.opening": "正在打开文件...", + "workspaceEditor.save": "保存", + "workspaceEditor.saveAll": "全部保存", + "workspaceEditor.find": "查找", + "workspaceEditor.replace": "替换", + "workspaceEditor.reload": "重新加载", + "workspaceEditor.reloadFromDisk": "重新加载磁盘版本", + "workspaceEditor.close": "关闭编辑器", + "workspaceEditor.closeTab": "关闭文件", + "workspaceEditor.context.undo": "撤销", + "workspaceEditor.context.redo": "重做", + "workspaceEditor.context.cut": "剪切", + "workspaceEditor.context.copy": "复制", + "workspaceEditor.context.paste": "粘贴", + "workspaceEditor.context.selectAll": "全选", + "workspaceEditor.noFile": "无文件", + "workspaceEditor.lines": "行", + "workspaceEditor.unsaved": "未保存", + "workspaceEditor.openFailed": "打开文件失败", + "workspaceEditor.saveFailed": "保存失败", + "workspaceEditor.reloadFailed": "重新加载失败", + "workspaceEditor.conflictMessage": "文件已在磁盘上改变,请重新加载后再保存。", + "workspaceEditor.cancel": "取消", + "workspaceEditor.discard": "放弃修改", + "workspaceEditor.closeDirtyTitle": "关闭编辑器前保存修改?", + "workspaceEditor.closeDirtyDescription": + "还有未保存的文件。你可以保存全部修改、放弃修改,或返回继续编辑。", + "workspaceEditor.closeTabDirtyTitle": "关闭文件前保存修改?", + "workspaceEditor.closeTabDirtyDescription": + "此文件有未保存修改。你可以保存、放弃修改,或返回继续编辑。", + "workspaceEditor.reloadDirtyTitle": "重新加载前放弃当前修改?", + "workspaceEditor.reloadDirtyDescription": "重新加载会用磁盘版本替换当前编辑内容。", /* ── Settings Nav ── */ "settings.navSystem": "系统设置", @@ -1194,8 +1231,9 @@ export const translations: Record> = { "chat.workspaceRemove": "Remove workspace", "chat.workspaceBrowseInFileTree": "Browse in File Tree", "chat.workspaceBrowseInSystemFileManager": "Browse in System File Manager", - "chat.workspaceRemoveConfirm": "Remove \"{name}\"?", - "chat.workspaceRemoveRunning": "A background task is running, so this workspace cannot be removed yet.", + "chat.workspaceRemoveConfirm": 'Remove "{name}"?', + "chat.workspaceRemoveRunning": + "A background task is running, so this workspace cannot be removed yet.", "chat.workspaceRemoveDescription": "This deletes conversations under the workspace, but it does not delete the folder.", "chat.workspaceOpenSystemFileManagerFailed": "Failed to open the file manager", @@ -1208,7 +1246,8 @@ export const translations: Record> = { "Projects and conversation history will not be deleted, but running commands will be stopped.", "chat.exitConfirmContinue": "Exit anyway", "chat.exitConfirmClose": "Close exit confirmation", - "chat.workspaceRemoveTerminalDescription": "Deleting the project will close these Terminal processes.", + "chat.workspaceRemoveTerminalDescription": + "Deleting the project will close these Terminal processes.", "chat.workspaceRemoveConfirmContinue": "Delete project", "chat.workspaceRemoveConfirmClose": "Close project deletion confirmation", "chat.conversationMore": "More actions", @@ -1217,7 +1256,7 @@ export const translations: Record> = { "chat.conversationRename": "Rename", "chat.conversationShare": "Share", "chat.conversationDelete": "Delete conversation", - "chat.conversationDeleteConfirm": "Delete \"{title}\"?", + "chat.conversationDeleteConfirm": 'Delete "{title}"?', "chat.conversationDeleteWarning": "This cannot be undone", "chat.statusPinned": "Pinned", "chat.statusShared": "Shared", @@ -1366,7 +1405,8 @@ export const translations: Record> = { "sharedHistory.refresh": "Refresh", "sharedHistory.emptyFilteredTitle": "No shared conversations match", "sharedHistory.emptyTitle": "No shared conversations yet", - "sharedHistory.emptyFilteredDesc": "Try another keyword to search titles, models, or conversation paths.", + "sharedHistory.emptyFilteredDesc": + "Try another keyword to search titles, models, or conversation paths.", "sharedHistory.emptyDesc": "Shared conversations will appear here so you can review link state and disable public access.", "sharedHistory.timeUnknown": "Time unknown", @@ -1590,11 +1630,49 @@ export const translations: Record> = { "projectTools.fileTree.resultsTruncated": "Results truncated", "projectTools.fileTree.newFile": "New File", "projectTools.fileTree.newFolder": "New Folder", + "projectTools.fileTree.openFile": "Open File", "projectTools.fileTree.rename": "Rename", "projectTools.fileTree.delete": "Delete", "projectTools.fileTree.copyPath": "Copy Path", "projectTools.fileTree.copiedPath": "Copied Path", "projectTools.fileTree.insertReference": "Insert Reference", + "workspaceEditor.loading": "Loading editor...", + "workspaceEditor.title": "Code Editor", + "workspaceEditor.empty": "No file open", + "workspaceEditor.emptyHint": "Double-click an editable file in the file tree", + "workspaceEditor.opening": "Opening file...", + "workspaceEditor.save": "Save", + "workspaceEditor.saveAll": "Save All", + "workspaceEditor.find": "Find", + "workspaceEditor.replace": "Replace", + "workspaceEditor.reload": "Reload", + "workspaceEditor.reloadFromDisk": "Reload from disk", + "workspaceEditor.close": "Close editor", + "workspaceEditor.closeTab": "Close file", + "workspaceEditor.context.undo": "Undo", + "workspaceEditor.context.redo": "Redo", + "workspaceEditor.context.cut": "Cut", + "workspaceEditor.context.copy": "Copy", + "workspaceEditor.context.paste": "Paste", + "workspaceEditor.context.selectAll": "Select All", + "workspaceEditor.noFile": "No file", + "workspaceEditor.lines": "lines", + "workspaceEditor.unsaved": "Unsaved", + "workspaceEditor.openFailed": "Failed to open file", + "workspaceEditor.saveFailed": "Save failed", + "workspaceEditor.reloadFailed": "Reload failed", + "workspaceEditor.conflictMessage": "The file changed on disk. Reload it before saving.", + "workspaceEditor.cancel": "Cancel", + "workspaceEditor.discard": "Discard", + "workspaceEditor.closeDirtyTitle": "Save changes before closing the editor?", + "workspaceEditor.closeDirtyDescription": + "Some files have unsaved changes. Save all changes, discard them, or return to editing.", + "workspaceEditor.closeTabDirtyTitle": "Save changes before closing this file?", + "workspaceEditor.closeTabDirtyDescription": + "This file has unsaved changes. Save it, discard changes, or return to editing.", + "workspaceEditor.reloadDirtyTitle": "Discard current changes before reloading?", + "workspaceEditor.reloadDirtyDescription": + "Reloading replaces the current editor contents with the version on disk.", /* ── Settings Nav ── */ "settings.navSystem": "System", @@ -2217,7 +2295,8 @@ export const translations: Record> = { "settings.skillsHubToggleDisable": "Disable Skills", "settings.skillsHubInstalledTab": "Installed", "settings.skillsHubStoreTab": "Skills Store", - "settings.skillsHubScanning": "Scanning the fixed Skills directory and syncing available conversation capabilities", + "settings.skillsHubScanning": + "Scanning the fixed Skills directory and syncing available conversation capabilities", "settings.skillsHubDeleteSkill": "Delete Skill", "settings.skillsHubLoadFailed": "Failed to load skills", "settings.skillsHubStoreLoadFailed": "Failed to load Skills Store", diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index 307d7366a..3a4cba563 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1990,3 +1990,19 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { -webkit-user-select: none; user-select: none; } + +@keyframes editorContextMenuIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.editor-context-menu { + animation: editorContextMenuIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} diff --git a/crates/agent-gui/src/lib/monacoNls.ts b/crates/agent-gui/src/lib/monacoNls.ts new file mode 100644 index 000000000..acc98a797 --- /dev/null +++ b/crates/agent-gui/src/lib/monacoNls.ts @@ -0,0 +1,49 @@ +import { DEFAULT_LOCALE, type Locale } from "../i18n/config"; + +type MonacoNlsGlobals = typeof globalThis & { + _VSCODE_NLS_LANGUAGE?: string; + _VSCODE_NLS_MESSAGES?: string[]; +}; + +let preferredLocale: Locale = DEFAULT_LOCALE; +let configuredLocale: Locale | null = null; +let localeLocked = false; +let zhCnMessagesPromise: Promise | null = null; + +function clearMonacoNlsGlobals() { + const target = globalThis as MonacoNlsGlobals; + delete target._VSCODE_NLS_LANGUAGE; + delete target._VSCODE_NLS_MESSAGES; +} + +export function setPreferredMonacoNlsLocale(locale: Locale) { + if (localeLocked) return; + preferredLocale = locale; +} + +export async function preparePreferredMonacoNlsLocale() { + const targetLocale = preferredLocale; + if (localeLocked || configuredLocale === targetLocale) return; + + if (targetLocale === "zh-CN") { + zhCnMessagesPromise ??= import("monaco-editor/esm/nls.messages.zh-cn.js").then( + () => undefined, + ); + await zhCnMessagesPromise; + if (localeLocked) return; + if (preferredLocale !== targetLocale) { + clearMonacoNlsGlobals(); + configuredLocale = "en-US"; + return; + } + configuredLocale = "zh-CN"; + return; + } + + clearMonacoNlsGlobals(); + configuredLocale = "en-US"; +} + +export function lockMonacoNlsLocale() { + localeLocked = true; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 6973998cc..35b4238e2 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -3,7 +3,16 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Suspense, + lazy, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { MacOsTitleBarSpacer, MacOsTitleBarToggle } from "../components/MacOsTitleBarSpacer"; import { ChatHistorySidebar } from "../components/chat/ChatHistorySidebar"; import { HistoryShareModal } from "../components/chat/HistoryShareModal"; @@ -18,6 +27,7 @@ import { type NotifyItem, NotifyToast } from "../components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "../components/chat/SharedHistoryManagerModal"; import { Ban, PanelRightClose, PanelRightOpen, Terminal, Upload } from "../components/icons"; import { ProjectToolsPanel } from "../components/project-tools/ProjectToolsPanel"; +import type { WorkspaceCodeEditorOpenRequest } from "../components/workspace-editor/WorkspaceCodeEditorOverlay"; import { Button } from "../components/ui/button"; import { useConfirmDialog } from "../components/ui/confirm-dialog"; import { useLocale } from "../i18n"; @@ -81,6 +91,11 @@ import { createSubagentRuntimeManager } from "../lib/chat/subagent/subagentRunti import { createStreamDebugLogger } from "../lib/debug/agentDebug"; import { createConversationHookDispatcher } from "../lib/hooks/conversationHooks"; import { tauriGitClient } from "../lib/git/tauriGitClient"; +import { + lockMonacoNlsLocale, + preparePreferredMonacoNlsLocale, + setPreferredMonacoNlsLocale, +} from "../lib/monacoNls"; import { createModelFromConfig, toModelValue } from "../lib/providers/llm"; import { type AppSettings, @@ -175,6 +190,15 @@ import { McpHubPage } from "./mcp-hub/McpHubPage"; import type { SectionId } from "./settings/types"; import { SkillsHubPage } from "./skills-hub/SkillsHubPage"; +const WorkspaceCodeEditorOverlay = lazy(async () => { + await preparePreferredMonacoNlsLocale(); + const module = await import("../components/workspace-editor/WorkspaceCodeEditorOverlay"); + lockMonacoNlsLocale(); + return { + default: module.WorkspaceCodeEditorOverlay, + }; +}); + type ChatPageProps = { settings: AppSettings; setSettings: (updater: (prev: AppSettings) => AppSettings) => void; @@ -548,6 +572,8 @@ function createWorkspaceProjectFromPath(path: string, kind: WorkspaceProject["ki export function ChatPage(props: ChatPageProps) { const { settings, setSettings, context, setContext, onOpenSettings, onToggleTheme } = props; + // Monaco reads NLS globals while the lazy editor module imports monaco-editor. + setPreferredMonacoNlsLocale(settings.locale); const { t } = useLocale(); const initialConversationRef = useRef(createConversationIdentity()); const initialConversationStateRef = useRef(createConversationStateFromContext(context)); @@ -664,6 +690,11 @@ export function ChatPage(props: ChatPageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); + const [workspaceEditorOpenRequest, setWorkspaceEditorOpenRequest] = + useState(null); + const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0); + const workspaceEditorRequestIdRef = useRef(0); const [projectTerminalSessions, setProjectTerminalSessions] = useState([]); const [remoteRuntimeStatus, setRemoteRuntimeStatus] = useState(() => buildFallbackGatewayStatus(settings.remote), @@ -1430,6 +1461,23 @@ export function ChatPage(props: ChatPageProps) { : !terminalProjectPath ? "Select a project to use project tools." : undefined; + const handleOpenEditableFile = useCallback( + (path: string) => { + if (!terminalProjectPath || !terminalProjectPathKey) return; + workspaceEditorRequestIdRef.current += 1; + setWorkspaceEditorOpen(true); + setWorkspaceEditorOpenRequest({ + id: workspaceEditorRequestIdRef.current, + projectPathKey: terminalProjectPathKey, + workdir: terminalProjectPath, + path, + }); + }, + [terminalProjectPath, terminalProjectPathKey], + ); + const requestWorkspaceEditorClose = useCallback(() => { + setWorkspaceEditorCloseRequestId((current) => current + 1); + }, []); useEffect(() => { if (!terminalProjectPathKey) { setProjectTerminalSessions([]); @@ -4106,298 +4154,322 @@ export function ChatPage(props: ChatPageProps) { return (
- onOpenSettings()} - /> - {/* ---- Sidebar ---- */} - { - setActiveView("chat"); - if (activeView !== "chat" && isDraftConversation) { - return; - } - handleNewConversation(); - }} - onSelectConversation={(id) => { - setActiveView("chat"); - handleSelectConversation(id); - }} - onStartRenaming={handleStartRenaming} - onRenameDraftChange={setRenameDraft} - onCommitRename={handleCommitRename} - onCancelRename={handleCancelRename} - onSetPinned={handleSetPinned} - canShareConversations={canShareHistory} - sharedConversationCount={sharedHistoryItems.length} - onShareConversation={handleOpenShareModal} - onOpenSharedConversations={handleOpenSharedHistoryManager} - onDeleteConversation={handleDeleteConversation} - onLoadMore={loadMoreHistory} - onCloseSidebar={handleCloseSidebar} - onOpenSkillsHub={() => { - cacheActiveComposerDraft(); - setProjectToolsPanelOpen(false); - setActiveView("skills-hub"); - }} - onOpenMcpHub={() => { - cacheActiveComposerDraft(); - setProjectToolsPanelOpen(false); - setActiveView("mcp-hub"); - }} - /> - - {shareConversation ? ( - + onOpenSettings()} /> - ) : null} - - {sharedManagerOpen ? ( - setSharedManagerOpen(false)} + {/* ---- Sidebar ---- */} + { + setActiveView("chat"); + if (activeView !== "chat" && isDraftConversation) { + return; + } + handleNewConversation(); + }} + onSelectConversation={(id) => { + setActiveView("chat"); + handleSelectConversation(id); + }} + onStartRenaming={handleStartRenaming} + onRenameDraftChange={setRenameDraft} + onCommitRename={handleCommitRename} + onCancelRename={handleCancelRename} + onSetPinned={handleSetPinned} + canShareConversations={canShareHistory} + sharedConversationCount={sharedHistoryItems.length} + onShareConversation={handleOpenShareModal} + onOpenSharedConversations={handleOpenSharedHistoryManager} + onDeleteConversation={handleDeleteConversation} + onLoadMore={loadMoreHistory} + onCloseSidebar={handleCloseSidebar} + onOpenSkillsHub={() => { + cacheActiveComposerDraft(); + setProjectToolsPanelOpen(false); + setActiveView("skills-hub"); + }} + onOpenMcpHub={() => { + cacheActiveComposerDraft(); + setProjectToolsPanelOpen(false); + setActiveView("mcp-hub"); + }} /> - ) : null} - - {confirmDialog} - - {/* ---- Main content ---- */} -
- {activeView === "skills-hub" ? ( - - ) : activeView === "mcp-hub" ? ( - setSharedManagerOpen(false)} /> - ) : ( - <> - -
- setProjectToolsPanelOpen((open) => !open)} - disabled={Boolean(terminalDisabledMessage) && !projectToolsPanelOpen} - aria-expanded={projectToolsPanelOpen} - title={ - projectToolsPanelOpen - ? "Collapse project tools panel" - : (terminalDisabledMessage ?? "Expand project tools panel") - } - className={`relative h-8 w-8 rounded-lg text-muted-foreground transition-[background-color,color,transform] duration-150 hover:text-foreground active:scale-95 ${ - projectToolsPanelOpen ? "bg-muted text-foreground" : "" - }`} - > - {projectToolsPanelOpen ? ( - - ) : ( - - )} - {projectTerminalSessions.length > 0 ? ( - - {projectTerminalSessions.length} - - ) : null} - - } - /> - -
- - + {activeView === "skills-hub" ? ( + - - - window.dispatchEvent( - new CustomEvent("liveagent:git-changed", { - detail: { workdir: gitWorkdir }, - }), - ) - } - onSend={handleSend} - onStop={handleStopSending} - onComposerBusyChange={handleComposerBusyChange} - onChatRuntimeControlsChange={handleChatRuntimeControlsChange} - onPickReadableFiles={pickReadableFiles} - onPasteFiles={importReadableFiles} - pendingUploadedFiles={pendingUploadedFiles} - onRemovePendingUpload={removePendingUpload} + sidebarOpen={sidebarOpen} + onOpenSidebar={handleOpenSidebar} /> - {isFileDropActive ? ( - - setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open)) - } + onFileTreeOpenChange={(open) => { + setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open)); + if (!open) { + requestWorkspaceEditorClose(); + } + }} onFileTreeStateChange={(patch) => setSettings((prev) => updateProjectToolsFileTreeProjectState(prev, terminalProjectPathKey, patch), @@ -4460,6 +4535,7 @@ export function ChatPage(props: ChatPageProps) { composerRef.current?.insertFileMention(path, kind); composerRef.current?.focus(); }} + onOpenEditableFile={handleOpenEditableFile} onInsertCommitMention={(commit) => { composerRef.current?.insertCommitMention(commit); composerRef.current?.focus(); diff --git a/crates/agent-gui/src/vite-env.d.ts b/crates/agent-gui/src/vite-env.d.ts index c1fe4fd6f..bb0d7f113 100644 --- a/crates/agent-gui/src/vite-env.d.ts +++ b/crates/agent-gui/src/vite-env.d.ts @@ -1,6 +1,8 @@ /// /// +declare module "monaco-editor/esm/nls.messages.zh-cn.js" {} + declare const __LIVEAGENT_APP_VERSION__: string; declare module "~icons/*?raw" { From 6b2edc7d211f5a2efd5be59f90c6e6fb49f473aa Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 01:21:00 +0800 Subject: [PATCH 02/10] perf(git-review): keep large diff resizing responsive --- .../project-tools/GitReviewPanel.tsx | 8 +- .../project-tools/ProjectToolsPanel.tsx | 49 ++++++++---- crates/agent-gateway/web/src/index.css | 76 +++++++++++++++++++ .../project-tools/GitReviewPanel.tsx | 17 ++++- .../project-tools/ProjectToolsPanel.tsx | 45 +++++++---- crates/agent-gui/src/index.css | 76 +++++++++++++++++++ 6 files changed, 238 insertions(+), 33 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index 13939c922..ff1b77f27 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -2397,7 +2397,7 @@ function assertGitOperationResult(value: unknown, fallbackMessage: string) { } } -export function GitReviewPanel(props: { +type GitReviewPanelProps = { cwd: string; gitClient?: GitClient | null; canWrite?: boolean; @@ -2405,7 +2405,9 @@ export function GitReviewPanel(props: { onRevealInFileTree?: (path: string) => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; -}) { +}; + +export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanelProps) { const { cwd, gitClient, @@ -4837,4 +4839,4 @@ export function GitReviewPanel(props: { ) : null}
); -} +}); diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx index 4a5f3ee2c..bb4fee61d 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx @@ -64,6 +64,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { isOpen: boolean; @@ -133,6 +134,14 @@ function clampPanelWidth(width: number, maxWidth: number) { return Math.min(maxWidth, Math.max(MIN_PANEL_WIDTH, width)); } +function panelWidthStyleValue(width: number) { + return `${Math.round(width)}px`; +} + +function applyPanelWidthStyle(panel: HTMLElement | null, width: number) { + panel?.style.setProperty("--project-tools-panel-width", panelWidthStyleValue(width)); +} + function areSessionsEqual(left: TerminalSession[], right: TerminalSession[]) { if (left.length !== right.length) return false; return left.every((session, index) => { @@ -655,7 +664,10 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { maxScrollLeft: number; } | null>(null); const panelWidth = clampPanelWidth(draftWidth, maxPanelWidth); - const panelStyle = { "--project-tools-panel-width": `${panelWidth}px` } as CSSProperties; + const panelStyleWidth = resizingRef.current ? pendingResizeWidthRef.current : panelWidth; + const panelStyle = { + "--project-tools-panel-width": panelWidthStyleValue(panelStyleWidth), + } as CSSProperties; const effectiveWidthCollapsed = !isOpen && collapseImmediately ? true : widthCollapsed; const effectiveShouldRenderContent = !isOpen && collapseImmediately ? false : shouldRenderContent; const isControlled = externalSessions !== undefined; @@ -735,6 +747,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } as CSSProperties; const updateTabsScrollState = useCallback(() => { + if (resizingRef.current) return; const element = tabsScrollRef.current; const next = element ? { @@ -833,6 +846,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { useEffect(() => { if (resizingRef.current) return; pendingResizeWidthRef.current = clampedWidth; + applyPanelWidthStyle(panelRef.current, clampedWidth); setDraftWidth(clampedWidth); }, [clampedWidth]); @@ -842,6 +856,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { let frameId = 0; const updateMaxWidth = () => { frameId = 0; + if (resizingRef.current) return; setMaxPanelWidth(getDynamicMaxPanelWidth(panel)); }; const scheduleUpdate = () => { @@ -1241,21 +1256,25 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { event.preventDefault(); resizeCleanupRef.current?.(); const startX = event.clientX; - const startWidth = panelWidth; + const dragMaxWidth = getDynamicMaxPanelWidth(panelRef.current); + const startWidth = clampPanelWidth(panelWidth, dragMaxWidth); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; resizingRef.current = true; + setMaxPanelWidth(dragMaxWidth); setIsResizing(true); pendingResizeWidthRef.current = startWidth; + applyPanelWidthStyle(panelRef.current, startWidth); + panelRef.current?.setAttribute("data-project-tools-resizing", "true"); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; - const scheduleDraftWidth = (nextWidth: number) => { + const schedulePanelWidth = (nextWidth: number) => { pendingResizeWidthRef.current = nextWidth; if (resizeFrameRef.current !== null) return; resizeFrameRef.current = window.requestAnimationFrame(() => { resizeFrameRef.current = null; - setDraftWidth(pendingResizeWidthRef.current); + applyPanelWidthStyle(panelRef.current, pendingResizeWidthRef.current); }); }; @@ -1263,6 +1282,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); window.removeEventListener("blur", handleUp); + if (resizeFrameRef.current !== null) { + window.cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = null; + } + panelRef.current?.removeAttribute("data-project-tools-resizing"); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; resizingRef.current = false; @@ -1270,25 +1294,21 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { }; const handleMove = (moveEvent: globalThis.MouseEvent) => { - const nextWidth = clampPanelWidth( - startWidth + startX - moveEvent.clientX, - getDynamicMaxPanelWidth(panelRef.current), - ); - scheduleDraftWidth(nextWidth); + const nextWidth = clampPanelWidth(startWidth + startX - moveEvent.clientX, dragMaxWidth); + schedulePanelWidth(nextWidth); }; const handleUp = () => { cleanupResize(); - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = null; - } const finalWidth = pendingResizeWidthRef.current; + applyPanelWidthStyle(panelRef.current, finalWidth); setDraftWidth(finalWidth); if (finalWidth !== clampedWidth) { onWidthChange(finalWidth); } setIsResizing(false); + window.dispatchEvent(new Event(PROJECT_TOOLS_RESIZE_END_EVENT)); + window.requestAnimationFrame(updateTabsScrollState); }; resizeCleanupRef.current = cleanupResize; @@ -1296,7 +1316,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { window.addEventListener("mouseup", handleUp); window.addEventListener("blur", handleUp); }, - [clampedWidth, onWidthChange, panelWidth], + [clampedWidth, onWidthChange, panelWidth, updateTabsScrollState], ); const handleTabsWheel = useCallback( @@ -1496,6 +1516,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { aria-hidden={!isOpen} inert={!isOpen} data-state={isOpen ? "open" : "closed"} + data-project-tools-resizing={isResizing ? "true" : undefined} className={cn( "project-tools-panel fixed inset-x-0 bottom-0 z-40 flex h-[min(72vh,34rem)] min-h-0 w-full shrink-0 flex-col overflow-hidden bg-background shadow-2xl transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none md:relative md:inset-auto md:z-10 md:h-full md:overflow-visible md:shadow-none", isOpen diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index b98a66e3c..5fde2ca63 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -1721,6 +1721,82 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { user-select: none; } +/* Keep right-sidebar drag responsive when very large diff DOM is mounted. */ +[data-project-tools-resizing="true"] .git-review-diff-selectable { + contain: layout paint style; + isolation: isolate; + position: relative; +} +[data-project-tools-resizing="true"] .git-review-diff-selectable::before { + content: ""; + display: block; + flex: 1 1 auto; + margin: 0.75rem; + min-height: 0; + border: 1px solid hsl(var(--border) / 0.72); + border-radius: 8px; + background: + linear-gradient( + 90deg, + hsl(var(--muted) / 0.62) 0 4.25rem, + hsl(var(--border) / 0.74) 4.25rem calc(4.25rem + 1px), + transparent calc(4.25rem + 1px) + ), + linear-gradient( + to bottom, + transparent 0 3.5rem, + hsl(142 72% 42% / 0.09) 3.5rem 5.25rem, + transparent 5.25rem 7rem, + hsl(var(--destructive) / 0.08) 7rem 8.75rem, + transparent 8.75rem 10.5rem, + hsl(38 92% 50% / 0.08) 10.5rem 12.25rem, + transparent 12.25rem + ), + repeating-linear-gradient( + to bottom, + transparent 0 27px, + hsl(var(--border) / 0.42) 27px 28px + ), + hsl(var(--background)); + box-shadow: + inset 0 1px 0 hsl(var(--foreground) / 0.04), + 0 1px 2px hsl(var(--foreground) / 0.04); +} +[data-project-tools-resizing="true"] .git-review-diff-selectable::after { + content: ""; + position: absolute; + inset: 1.25rem 1.5rem 1.25rem 5.8rem; + z-index: 1; + border-radius: 4px; + background: + repeating-linear-gradient( + to bottom, + hsl(var(--muted-foreground) / 0.16) 0 7px, + transparent 7px 84px + ), + repeating-linear-gradient( + to bottom, + transparent 0 28px, + hsl(var(--muted-foreground) / 0.11) 28px 35px, + transparent 35px 84px + ), + repeating-linear-gradient( + to bottom, + transparent 0 56px, + hsl(var(--muted-foreground) / 0.13) 56px 63px, + transparent 63px 84px + ); + background-repeat: repeat-y; + background-size: + 56% 84px, + 82% 84px, + 42% 84px; + pointer-events: none; +} +[data-project-tools-resizing="true"] .git-review-diff-selectable > * { + display: none; +} + @keyframes editorContextMenuIn { from { opacity: 0; diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx index 6d82a2c4e..9161995a2 100644 --- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx @@ -79,6 +79,7 @@ const GIT_REVIEW_STACKED_PANE_BUTTON_CLASS = const DIFF_SELECTION_AUTOSCROLL_EDGE_PX = 40; const DIFF_SELECTION_AUTOSCROLL_MAX_STEP_PX = 22; const DIFF_HORIZONTAL_SCROLLBAR_MIN_THUMB_PX = 32; +const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; const gitReviewScrollbarTimers = new WeakMap(); type GitReviewScrollbarAxis = "vertical" | "horizontal"; type GitReviewScrollbarOverlay = { @@ -483,6 +484,10 @@ function chooseDiffHorizontalScrollTarget( return bestTarget; } +function isProjectToolsPanelResizing(root: HTMLElement | null) { + return Boolean(root?.closest('[data-project-tools-resizing="true"]')); +} + type PatchChunk = { key: string; label: string; @@ -1371,6 +1376,8 @@ function DiffContent(props: { const updateDiffHorizontalScrollbar = useCallback(() => { const root = rootRef.current; + if (isProjectToolsPanelResizing(root)) return; + const trackWidth = diffHorizontalScrollbarTrackRef.current?.clientWidth ?? scrollViewportRef.current?.clientWidth ?? @@ -1503,6 +1510,7 @@ function DiffContent(props: { }); mutationObserver?.observe(root, { childList: true, subtree: true }); window.addEventListener("resize", refreshTargets); + window.addEventListener(PROJECT_TOOLS_RESIZE_END_EVENT, refreshTargets); refreshTargets(); return () => { @@ -1510,6 +1518,7 @@ function DiffContent(props: { window.cancelAnimationFrame(animationFrame); } window.removeEventListener("resize", refreshTargets); + window.removeEventListener(PROJECT_TOOLS_RESIZE_END_EVENT, refreshTargets); mutationObserver?.disconnect(); detachTargets(); resizeObserver?.disconnect(); @@ -2707,7 +2716,7 @@ function GitOperationNoticeToast({ ); } -export function GitReviewPanel(props: { +type GitReviewPanelProps = { cwd: string; gitClient?: GitClient | null; canWrite?: boolean; @@ -2715,7 +2724,9 @@ export function GitReviewPanel(props: { onRevealInFileTree?: (path: string) => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; -}) { +}; + +export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanelProps) { const { cwd, gitClient, @@ -5234,4 +5245,4 @@ export function GitReviewPanel(props: { ) : null}
); -} +}); diff --git a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx index 08ce2a94d..29a183c7d 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx @@ -63,6 +63,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { isOpen: boolean; @@ -132,6 +133,14 @@ function clampPanelWidth(width: number, maxWidth: number) { return Math.min(maxWidth, Math.max(MIN_PANEL_WIDTH, width)); } +function panelWidthStyleValue(width: number) { + return `${Math.round(width)}px`; +} + +function applyPanelWidthStyle(panel: HTMLElement | null, width: number) { + panel?.style.setProperty("--project-tools-panel-width", panelWidthStyleValue(width)); +} + function areSessionsEqual(left: TerminalSession[], right: TerminalSession[]) { if (left.length !== right.length) return false; return left.every((session, index) => { @@ -642,7 +651,10 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const tabDragRef = useRef(null); const suppressedTabClickRef = useRef(""); const panelWidth = clampPanelWidth(draftWidth, maxPanelWidth); - const panelStyle = { "--project-tools-panel-width": `${panelWidth}px` } as CSSProperties; + const panelStyleWidth = resizingRef.current ? pendingResizeWidthRef.current : panelWidth; + const panelStyle = { + "--project-tools-panel-width": panelWidthStyleValue(panelStyleWidth), + } as CSSProperties; const effectiveWidthCollapsed = !isOpen && collapseImmediately ? true : widthCollapsed; const effectiveShouldRenderContent = !isOpen && collapseImmediately ? false : shouldRenderContent; const isControlled = externalSessions !== undefined; @@ -766,6 +778,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { useEffect(() => { if (resizingRef.current) return; pendingResizeWidthRef.current = clampedWidth; + applyPanelWidthStyle(panelRef.current, clampedWidth); setDraftWidth(clampedWidth); }, [clampedWidth]); @@ -775,6 +788,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { let frameId = 0; const updateMaxWidth = () => { frameId = 0; + if (resizingRef.current) return; setMaxPanelWidth(getDynamicMaxPanelWidth(panel)); }; const scheduleUpdate = () => { @@ -1114,21 +1128,25 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { event.preventDefault(); resizeCleanupRef.current?.(); const startX = event.clientX; - const startWidth = panelWidth; + const dragMaxWidth = getDynamicMaxPanelWidth(panelRef.current); + const startWidth = clampPanelWidth(panelWidth, dragMaxWidth); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; resizingRef.current = true; + setMaxPanelWidth(dragMaxWidth); setIsResizing(true); pendingResizeWidthRef.current = startWidth; + applyPanelWidthStyle(panelRef.current, startWidth); + panelRef.current?.setAttribute("data-project-tools-resizing", "true"); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; - const scheduleDraftWidth = (nextWidth: number) => { + const schedulePanelWidth = (nextWidth: number) => { pendingResizeWidthRef.current = nextWidth; if (resizeFrameRef.current !== null) return; resizeFrameRef.current = window.requestAnimationFrame(() => { resizeFrameRef.current = null; - setDraftWidth(pendingResizeWidthRef.current); + applyPanelWidthStyle(panelRef.current, pendingResizeWidthRef.current); }); }; @@ -1136,6 +1154,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); window.removeEventListener("blur", handleUp); + if (resizeFrameRef.current !== null) { + window.cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = null; + } + panelRef.current?.removeAttribute("data-project-tools-resizing"); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; resizingRef.current = false; @@ -1143,25 +1166,20 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { }; const handleMove = (moveEvent: globalThis.MouseEvent) => { - const nextWidth = clampPanelWidth( - startWidth + startX - moveEvent.clientX, - getDynamicMaxPanelWidth(panelRef.current), - ); - scheduleDraftWidth(nextWidth); + const nextWidth = clampPanelWidth(startWidth + startX - moveEvent.clientX, dragMaxWidth); + schedulePanelWidth(nextWidth); }; const handleUp = () => { cleanupResize(); - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = null; - } const finalWidth = pendingResizeWidthRef.current; + applyPanelWidthStyle(panelRef.current, finalWidth); setDraftWidth(finalWidth); if (finalWidth !== clampedWidth) { onWidthChange(finalWidth); } setIsResizing(false); + window.dispatchEvent(new Event(PROJECT_TOOLS_RESIZE_END_EVENT)); }; resizeCleanupRef.current = cleanupResize; @@ -1276,6 +1294,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { aria-hidden={!isOpen} inert={!isOpen} data-state={isOpen ? "open" : "closed"} + data-project-tools-resizing={isResizing ? "true" : undefined} className={cn( "fixed inset-x-0 bottom-0 z-40 flex h-[min(72vh,34rem)] min-h-0 w-full shrink-0 flex-col overflow-hidden bg-background shadow-2xl transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none md:relative md:inset-auto md:z-10 md:h-full md:overflow-visible md:shadow-none", isOpen diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index 3a4cba563..c4cb0d4ea 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1991,6 +1991,82 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { user-select: none; } +/* Keep right-sidebar drag responsive when very large diff DOM is mounted. */ +[data-project-tools-resizing="true"] .git-review-diff-selectable { + contain: layout paint style; + isolation: isolate; + position: relative; +} +[data-project-tools-resizing="true"] .git-review-diff-selectable::before { + content: ""; + display: block; + flex: 1 1 auto; + margin: 0.75rem; + min-height: 0; + border: 1px solid hsl(var(--border) / 0.72); + border-radius: 8px; + background: + linear-gradient( + 90deg, + hsl(var(--muted) / 0.62) 0 4.25rem, + hsl(var(--border) / 0.74) 4.25rem calc(4.25rem + 1px), + transparent calc(4.25rem + 1px) + ), + linear-gradient( + to bottom, + transparent 0 3.5rem, + hsl(142 72% 42% / 0.09) 3.5rem 5.25rem, + transparent 5.25rem 7rem, + hsl(var(--destructive) / 0.08) 7rem 8.75rem, + transparent 8.75rem 10.5rem, + hsl(38 92% 50% / 0.08) 10.5rem 12.25rem, + transparent 12.25rem + ), + repeating-linear-gradient( + to bottom, + transparent 0 27px, + hsl(var(--border) / 0.42) 27px 28px + ), + hsl(var(--background)); + box-shadow: + inset 0 1px 0 hsl(var(--foreground) / 0.04), + 0 1px 2px hsl(var(--foreground) / 0.04); +} +[data-project-tools-resizing="true"] .git-review-diff-selectable::after { + content: ""; + position: absolute; + inset: 1.25rem 1.5rem 1.25rem 5.8rem; + z-index: 1; + border-radius: 4px; + background: + repeating-linear-gradient( + to bottom, + hsl(var(--muted-foreground) / 0.16) 0 7px, + transparent 7px 84px + ), + repeating-linear-gradient( + to bottom, + transparent 0 28px, + hsl(var(--muted-foreground) / 0.11) 28px 35px, + transparent 35px 84px + ), + repeating-linear-gradient( + to bottom, + transparent 0 56px, + hsl(var(--muted-foreground) / 0.13) 56px 63px, + transparent 63px 84px + ); + background-repeat: repeat-y; + background-size: + 56% 84px, + 82% 84px, + 42% 84px; + pointer-events: none; +} +[data-project-tools-resizing="true"] .git-review-diff-selectable > * { + display: none; +} + @keyframes editorContextMenuIn { from { opacity: 0; From 6aea95f6af6f119d10d8a97a88d846e37f7f0bad Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 02:53:14 +0800 Subject: [PATCH 03/10] fix(chat): keep history sidebar usable on mobile --- .../components/chat/ChatHistorySidebar.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx index 69e10ea9c..7b9b83ff9 100644 --- a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx @@ -118,6 +118,13 @@ const SIDEBAR_SECTION_CHEVRON_CLASS = "h-3.5 w-3.5 shrink-0 transition-transform duration-300 ease-out motion-reduce:transition-none"; const SIDEBAR_PROJECT_MIN_BODY_HEIGHT = 96; const SIDEBAR_RECENT_MIN_BODY_HEIGHT = 160; +// Default share of the available height the workspace (projects) section claims +// before the user drags the resize handle. Desktop splits evenly; on mobile the +// resize handle is hidden, so bias toward the recent-conversation list — the +// primary content of the drawer — by giving the workspace a smaller default +// share so the recent section sits a little higher and gets a little more room. +const SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO = 0.5; +const SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO = 0.4; const EMPTY_PROJECT_PATH_KEYS = new Set(); const EMPTY_PROJECT_ACTIVITY_UPDATED_ATS = new Map(); const HISTORY_LOADING_SKELETON_ROWS = [ @@ -1163,8 +1170,11 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi 0, Math.min(projectsContentHeight, projectMinBodyHeight, resizeMaxHeight), ); + const projectsBodyDefaultRatio = isMobileMenuLayout + ? SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO + : SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO; const defaultProjectsBodyHeight = clampSidebarSectionHeight( - Math.min(projectsContentHeight, Math.floor(available / 2)), + Math.min(projectsContentHeight, Math.floor(available * projectsBodyDefaultRatio)), resizeMinHeight, resizeMaxHeight, ); @@ -1202,6 +1212,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi return { projectsBodyHeight, resizeMinHeight, resizeMaxHeight, gridTemplateRows }; }, [ + isMobileMenuLayout, projectSectionHeight, projectsCollapsed, recentCollapsed, @@ -1719,9 +1730,14 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi disabled={!canResizeProjectSections} onPointerDown={handleProjectSectionResizeStart} className={cn( + // Always render the handle as a grid item so it keeps occupying its + // grid-template-rows track. `hidden` (display:none) would drop it from + // the grid below `md`, auto-shifting the recent-conversation body out of + // its sized track and collapsing the list to ~0 height on mobile. The + // draggable handle only becomes visible from `md` upwards. "group items-center justify-center border-0 bg-transparent p-0 focus-visible:outline-none", canResizeProjectSections - ? "hidden h-2 cursor-row-resize touch-none md:flex" + ? "flex h-0 overflow-hidden cursor-row-resize touch-none md:h-2 md:overflow-visible" : "flex h-0 overflow-hidden", )} > From 821840318ccd26ce080efe44c7778e80d98a4e7c Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 03:12:27 +0800 Subject: [PATCH 04/10] fix(settings): keep remote gateway port input editable --- .../test/webui/web-remote-input.test.mjs | 16 ++++ .../test/webui/web-settings.test.mjs | 5 ++ .../web/src/lib/settings/index.ts | 2 +- .../web/src/pages/settings/RemoteSection.tsx | 83 ++++++++++++++++--- .../web/src/pages/settings/remoteInput.ts | 29 +++++++ crates/agent-gui/src/lib/settings/index.ts | 2 +- .../src/pages/settings/RemoteSection.tsx | 83 ++++++++++++++++--- .../src/pages/settings/remoteInput.ts | 29 +++++++ .../test/settings/normalization.test.mjs | 5 ++ .../test/settings/remote-input.test.mjs | 16 ++++ 10 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 crates/agent-gateway/test/webui/web-remote-input.test.mjs create mode 100644 crates/agent-gateway/web/src/pages/settings/remoteInput.ts create mode 100644 crates/agent-gui/src/pages/settings/remoteInput.ts create mode 100644 crates/agent-gui/test/settings/remote-input.test.mjs diff --git a/crates/agent-gateway/test/webui/web-remote-input.test.mjs b/crates/agent-gateway/test/webui/web-remote-input.test.mjs new file mode 100644 index 000000000..46def815e --- /dev/null +++ b/crates/agent-gateway/test/webui/web-remote-input.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const remoteInput = loader.loadModule("src/pages/settings/remoteInput.ts"); + +test("web remote integer drafts stay editable while preserving valid values", () => { + assert.equal(remoteInput.normalizeIntegerDraftInput(":50051"), "50051"); + assert.equal(remoteInput.normalizeIntegerDraftInput(" 12abc34 "), "1234"); + + assert.equal(remoteInput.parseIntegerDraftValue("", { min: 1, max: 65_535 }), null); + assert.equal(remoteInput.parseIntegerDraftValue("0", { min: 1, max: 65_535 }), null); + assert.equal(remoteInput.parseIntegerDraftValue("443", { min: 1, max: 65_535 }), 443); + assert.equal(remoteInput.parseIntegerDraftValue("65536", { min: 1, max: 65_535 }), 65_535); +}); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index bf5bf3dac..5f28d7086 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -440,6 +440,11 @@ test("web remote settings normalize single-slash http gateway URLs", () => { assert.equal(remote.gatewayUrl, "https://gateway.example"); assert.equal(remote.grpcEndpoint, "https://grpc.example"); assert.equal(remote.token, "token"); + + const remoteWithOversizedPort = settings.normalizeRemoteSettings({ + grpcPort: "70000", + }); + assert.equal(remoteWithOversizedPort.grpcPort, 65_535); }); test("web cron task normalization preserves finite and exhausted run counts", () => { diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 4b046cc42..41d85387e 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -1047,7 +1047,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { return { enabled: obj.enabled === true, gatewayUrl: normalizeBaseUrl(typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : ""), - grpcPort: normalizePositiveInteger(obj.grpcPort, 50051), + grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 50051), grpcEndpoint: normalizeGrpcEndpoint(obj.grpcEndpoint), token: normalizeApiKey(typeof obj.token === "string" ? obj.token : ""), agentId: normalizeOptionalText(obj.agentId), diff --git a/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx b/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx index d0fefa57f..07f3a4dbe 100644 --- a/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Cloud, Copy, @@ -21,9 +21,12 @@ import { import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import type { AppSettings } from "../../lib/settings"; +import { normalizeIntegerDraftInput, parseIntegerDraftValue } from "./remoteInput"; import { AgentActivationSwitch } from "./shared"; import type { SettingsSectionProps } from "./types"; +const REMOTE_GRPC_PORT_MAX = 65_535; + function CopyButton({ value }: { value: string }) { const [copied, setCopied] = useState(false); @@ -103,6 +106,50 @@ function updateRemoteSettings( })); } +function usePositiveIntegerDraft( + value: number, + options: { min?: number; max?: number }, + onCommit: (nextValue: number) => void, +) { + const [draft, setDraft] = useState(() => String(value)); + + useEffect(() => { + setDraft(String(value)); + }, [value]); + + const handleChange = useCallback( + (rawValue: string) => { + const nextDraft = normalizeIntegerDraftInput(rawValue); + setDraft(nextDraft); + + const parsed = parseIntegerDraftValue(nextDraft, options); + if (parsed !== null && parsed !== value) { + onCommit(parsed); + } + }, + [onCommit, options, value], + ); + + const handleBlur = useCallback(() => { + const parsed = parseIntegerDraftValue(draft, options); + if (parsed === null) { + setDraft(String(value)); + return; + } + + setDraft(String(parsed)); + if (parsed !== value) { + onCommit(parsed); + } + }, [draft, onCommit, options, value]); + + return { + draft, + handleBlur, + handleChange, + }; +} + function buildGrpcEndpoint(settings: AppSettings["remote"]) { const explicitEndpoint = settings.grpcEndpoint.trim(); if (explicitEndpoint) { @@ -137,6 +184,22 @@ function formatTimestamp(value?: number | null) { export function RemoteSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); + const remoteGrpcPortDraft = usePositiveIntegerDraft( + settings.remote.grpcPort, + { min: 1, max: REMOTE_GRPC_PORT_MAX }, + (grpcPort) => + updateRemoteSettings(setSettings, { + grpcPort, + }), + ); + const remoteHeartbeatDraft = usePositiveIntegerDraft( + settings.remote.heartbeatInterval, + { min: 1 }, + (heartbeatInterval) => + updateRemoteSettings(setSettings, { + heartbeatInterval, + }), + ); const [status, setStatus] = useState({ online: false, enabled: settings.remote.enabled, @@ -258,12 +321,9 @@ export function RemoteSection(props: SettingsSectionProps) { - updateRemoteSettings(setSettings, { - grpcPort: Number.parseInt(e.target.value || "0", 10) || 50051, - }) - } + value={remoteGrpcPortDraft.draft} + onBlur={remoteGrpcPortDraft.handleBlur} + onChange={(e) => remoteGrpcPortDraft.handleChange(e.target.value)} placeholder="50051" className="w-24 shrink-0 font-mono text-[13px]" /> @@ -421,12 +481,9 @@ export function RemoteSection(props: SettingsSectionProps) { - updateRemoteSettings(setSettings, { - heartbeatInterval: Number.parseInt(e.target.value || "0", 10) || 30, - }) - } + value={remoteHeartbeatDraft.draft} + onBlur={remoteHeartbeatDraft.handleBlur} + onChange={(e) => remoteHeartbeatDraft.handleChange(e.target.value)} placeholder="30" className="w-24 font-mono text-[13px]" /> diff --git a/crates/agent-gateway/web/src/pages/settings/remoteInput.ts b/crates/agent-gateway/web/src/pages/settings/remoteInput.ts new file mode 100644 index 000000000..8fff70530 --- /dev/null +++ b/crates/agent-gateway/web/src/pages/settings/remoteInput.ts @@ -0,0 +1,29 @@ +export type IntegerDraftOptions = { + min?: number; + max?: number; +}; + +export function normalizeIntegerDraftInput(input: string): string { + return input.replace(/\D+/g, ""); +} + +export function parseIntegerDraftValue( + input: string, + options: IntegerDraftOptions = {}, +): number | null { + const draft = normalizeIntegerDraftInput(input); + if (!draft) return null; + + const value = Number.parseInt(draft, 10); + if (!Number.isFinite(value)) return null; + + const min = options.min ?? 1; + if (value < min) return null; + + const max = options.max; + if (typeof max === "number" && value > max) { + return max; + } + + return Number.isSafeInteger(value) ? value : null; +} diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 4e624d1c0..58c37e658 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -1039,7 +1039,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { return { enabled: obj.enabled === true, gatewayUrl: normalizeBaseUrl(typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : ""), - grpcPort: normalizePositiveInteger(obj.grpcPort, 50051), + grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 50051), grpcEndpoint: normalizeGrpcEndpoint(obj.grpcEndpoint), token: normalizeApiKey(typeof obj.token === "string" ? obj.token : ""), agentId: normalizeOptionalText(obj.agentId), diff --git a/crates/agent-gui/src/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index 3e721e527..f4ad670a0 100644 --- a/crates/agent-gui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gui/src/pages/settings/RemoteSection.tsx @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Check, Cloud, @@ -21,9 +21,12 @@ import { import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import type { AppSettings } from "../../lib/settings"; +import { normalizeIntegerDraftInput, parseIntegerDraftValue } from "./remoteInput"; import { AgentActivationSwitch } from "./shared"; import type { SettingsSectionProps } from "./types"; +const REMOTE_GRPC_PORT_MAX = 65_535; + function CopyButton({ value }: { value: string }) { const [copied, setCopied] = useState(false); @@ -107,6 +110,50 @@ function updateRemoteSettings( })); } +function usePositiveIntegerDraft( + value: number, + options: { min?: number; max?: number }, + onCommit: (nextValue: number) => void, +) { + const [draft, setDraft] = useState(() => String(value)); + + useEffect(() => { + setDraft(String(value)); + }, [value]); + + const handleChange = useCallback( + (rawValue: string) => { + const nextDraft = normalizeIntegerDraftInput(rawValue); + setDraft(nextDraft); + + const parsed = parseIntegerDraftValue(nextDraft, options); + if (parsed !== null && parsed !== value) { + onCommit(parsed); + } + }, + [onCommit, options, value], + ); + + const handleBlur = useCallback(() => { + const parsed = parseIntegerDraftValue(draft, options); + if (parsed === null) { + setDraft(String(value)); + return; + } + + setDraft(String(parsed)); + if (parsed !== value) { + onCommit(parsed); + } + }, [draft, onCommit, options, value]); + + return { + draft, + handleBlur, + handleChange, + }; +} + function buildGrpcEndpoint(settings: AppSettings["remote"]) { const explicitEndpoint = settings.grpcEndpoint.trim(); if (explicitEndpoint) { @@ -141,6 +188,22 @@ function formatTimestamp(value?: number | null) { export function RemoteSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); + const remoteGrpcPortDraft = usePositiveIntegerDraft( + settings.remote.grpcPort, + { min: 1, max: REMOTE_GRPC_PORT_MAX }, + (grpcPort) => + updateRemoteSettings(setSettings, { + grpcPort, + }), + ); + const remoteHeartbeatDraft = usePositiveIntegerDraft( + settings.remote.heartbeatInterval, + { min: 1 }, + (heartbeatInterval) => + updateRemoteSettings(setSettings, { + heartbeatInterval, + }), + ); const [status, setStatus] = useState({ online: false, enabled: settings.remote.enabled, @@ -285,12 +348,9 @@ export function RemoteSection(props: SettingsSectionProps) { - updateRemoteSettings(setSettings, { - grpcPort: Number.parseInt(e.target.value || "0", 10) || 50051, - }) - } + value={remoteGrpcPortDraft.draft} + onBlur={remoteGrpcPortDraft.handleBlur} + onChange={(e) => remoteGrpcPortDraft.handleChange(e.target.value)} placeholder="50051" className="w-24 shrink-0 font-mono text-[13px]" /> @@ -411,12 +471,9 @@ export function RemoteSection(props: SettingsSectionProps) { - updateRemoteSettings(setSettings, { - heartbeatInterval: Number.parseInt(e.target.value || "0", 10) || 30, - }) - } + value={remoteHeartbeatDraft.draft} + onBlur={remoteHeartbeatDraft.handleBlur} + onChange={(e) => remoteHeartbeatDraft.handleChange(e.target.value)} placeholder="30" className="w-24 font-mono text-[13px]" /> diff --git a/crates/agent-gui/src/pages/settings/remoteInput.ts b/crates/agent-gui/src/pages/settings/remoteInput.ts new file mode 100644 index 000000000..8fff70530 --- /dev/null +++ b/crates/agent-gui/src/pages/settings/remoteInput.ts @@ -0,0 +1,29 @@ +export type IntegerDraftOptions = { + min?: number; + max?: number; +}; + +export function normalizeIntegerDraftInput(input: string): string { + return input.replace(/\D+/g, ""); +} + +export function parseIntegerDraftValue( + input: string, + options: IntegerDraftOptions = {}, +): number | null { + const draft = normalizeIntegerDraftInput(input); + if (!draft) return null; + + const value = Number.parseInt(draft, 10); + if (!Number.isFinite(value)) return null; + + const min = options.min ?? 1; + if (value < min) return null; + + const max = options.max; + if (typeof max === "number" && value > max) { + return max; + } + + return Number.isSafeInteger(value) ? value : null; +} diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 31dc17e05..b65186a33 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -1452,4 +1452,9 @@ test("mcp and remote settings normalize transport, selection, ports, and tokens" assert.equal(remote.token, "secret"); assert.equal(remote.autoReconnect, false); assert.equal(remote.heartbeatInterval, 15); + + const remoteWithOversizedPort = settings.normalizeRemoteSettings({ + grpcPort: "70000", + }); + assert.equal(remoteWithOversizedPort.grpcPort, 65_535); }); diff --git a/crates/agent-gui/test/settings/remote-input.test.mjs b/crates/agent-gui/test/settings/remote-input.test.mjs new file mode 100644 index 000000000..3d09ebc96 --- /dev/null +++ b/crates/agent-gui/test/settings/remote-input.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const remoteInput = loader.loadModule("src/pages/settings/remoteInput.ts"); + +test("remote integer drafts stay editable while preserving valid values", () => { + assert.equal(remoteInput.normalizeIntegerDraftInput(":50051"), "50051"); + assert.equal(remoteInput.normalizeIntegerDraftInput(" 12abc34 "), "1234"); + + assert.equal(remoteInput.parseIntegerDraftValue("", { min: 1, max: 65_535 }), null); + assert.equal(remoteInput.parseIntegerDraftValue("0", { min: 1, max: 65_535 }), null); + assert.equal(remoteInput.parseIntegerDraftValue("443", { min: 1, max: 65_535 }), 443); + assert.equal(remoteInput.parseIntegerDraftValue("65536", { min: 1, max: 65_535 }), 65_535); +}); From 30c10fcd5c25eb7bf1bd8210d120f5070222ea90 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 07:56:04 +0800 Subject: [PATCH 05/10] fix(webui): keep mobile code editor above project tools --- crates/agent-gateway/web/src/App.tsx | 2 +- .../workspace-editor/WorkspaceCodeEditorOverlay.tsx | 2 +- crates/agent-gateway/web/src/styles.css | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index 6eb8d2187..b099794fb 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -6295,7 +6295,7 @@ export default function App() { {workspaceEditorOpen ? ( +
{translate("workspaceEditor.loading", settings.locale)}
} diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index c21673240..c6be694bc 100644 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -763,7 +763,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp
Date: Tue, 2 Jun 2026 08:02:05 +0800 Subject: [PATCH 06/10] fix(project-tools): show launcher when terminal has no sessions --- .../web/src/components/project-tools/ProjectToolsPanel.tsx | 6 +++--- .../src/components/project-tools/ProjectToolsPanel.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx index bb4fee61d..3d5e5bf13 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx @@ -1412,8 +1412,8 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, []); - const showFirstOpenChooser = - projectReady && sessions.length === 0 && !fileTreeInitialized && !gitReviewInitialized; + const showProjectToolsChooser = + projectReady && currentActiveTab === "terminal" && !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1845,7 +1845,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
{disabledMessage}
- ) : showFirstOpenChooser ? ( + ) : showProjectToolsChooser ? (

diff --git a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx index 29a183c7d..546b9105a 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx @@ -1190,8 +1190,8 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { [clampedWidth, onWidthChange, panelWidth], ); - const showFirstOpenChooser = - projectReady && sessions.length === 0 && !fileTreeInitialized && !gitReviewInitialized; + const showProjectToolsChooser = + projectReady && currentActiveTab === "terminal" && !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1599,7 +1599,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
{disabledMessage}
- ) : showFirstOpenChooser ? ( + ) : showProjectToolsChooser ? (

From c766657dfb071b7ed88d056b42f2ca6d182230b9 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 09:36:15 +0800 Subject: [PATCH 07/10] fix(git): align history graph markers with fallback refs --- .../project-tools/GitReviewPanel.tsx | 470 ++++++++++++------ crates/agent-gateway/web/src/i18n/config.ts | 4 + .../agent-gateway/web/src/lib/git/gitGraph.ts | 200 +++++++- crates/agent-gateway/web/src/lib/git/types.ts | 8 + .../agent-gui/src-tauri/src/commands/git.rs | 129 ++++- .../project-tools/GitReviewPanel.tsx | 174 ++++++- crates/agent-gui/src/i18n/config.ts | 4 + crates/agent-gui/src/lib/git/gitGraph.ts | 200 +++++++- crates/agent-gui/src/lib/git/types.ts | 8 + .../agent-gui/test/tools/git-graph.test.mjs | 70 ++- 10 files changed, 1084 insertions(+), 183 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index ff1b77f27..652be29a7 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -25,6 +25,7 @@ import type { GitCommitFile, GitCommitSummary, GitDiffResponse, + GitLogResponse, GitOperationResponse, GitRepositoryState, GitStatusEntry, @@ -473,16 +474,26 @@ type ParsedDiffStat = { type DiffViewKind = "branch" | "workingTree"; type GitReviewMode = "changes" | "history"; type GitReviewStackedPane = "list" | "detail"; +type GitHistoryMarkerKind = Extract; +type GitHistoryGraphState = Pick< + GitLogResponse, + "historyBaseRef" | "historyAhead" | "historyBehind" | "mergeBase" +>; type GitHistoryRow = + | { + type: "marker"; + kind: GitHistoryMarkerKind; + graphIndex: number; + } | { type: "commit"; commit: GitCommitSummary; - commitIndex: number; + graphIndex: number; } | { type: "file"; commit: GitCommitSummary; - commitIndex: number; + graphIndex: number; file: GitCommitFile; } | { @@ -1766,6 +1777,30 @@ function commitHistoryTitle(commit: GitCommitSummary) { return refs.length > 0 ? `${label} - ${refs.join(", ")}` : label; } +function gitHistoryMarkerRef( + kind: GitHistoryMarkerKind, + state: Pick, + historyBaseRef: string, +) { + return kind === "outgoing-changes" ? state.head : historyBaseRef; +} + +const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { + historyBaseRef: "", + historyAhead: 0, + historyBehind: 0, + mergeBase: "", +}; + +function gitHistoryGraphStateFromResponse(response: GitLogResponse): GitHistoryGraphState { + return { + historyBaseRef: response.historyBaseRef, + historyAhead: response.historyAhead, + historyBehind: response.historyBehind, + mergeBase: response.mergeBase, + }; +} + function CommitRefTags({ refs, selected, @@ -1822,14 +1857,48 @@ function CommitRefTags({ function GitGraphCommitMarker({ cx, color, + kind, isHead, isMerge, }: { cx: number; color: string; + kind: GraphRow["kind"]; isHead: boolean; isMerge: boolean; }) { + if (kind === "incoming-changes" || kind === "outgoing-changes") { + return ( + + + + + + ); + } + if (isHead) { return ( @@ -2021,6 +2090,7 @@ function GitGraphSvgCell({ row }: { row: GraphRow }) { @@ -2350,7 +2420,11 @@ function gitRepositoryStateSignature(state: GitRepositoryState) { return `${header}\x1d${entries}`; } -function gitHistorySignature(state: GitRepositoryState, commits: GitCommitSummary[]) { +function gitHistorySignature( + state: GitRepositoryState, + commits: GitCommitSummary[], + historyGraphState: GitHistoryGraphState, +) { const commitsSignature = commits .map((commit) => [ @@ -2367,7 +2441,7 @@ function gitHistorySignature(state: GitRepositoryState, commits: GitCommitSummar ].join("\x1e"), ) .join("\x1f"); - return `${gitRepositoryStateSignature(state)}\x1d${commitsSignature}`; + return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; } function gitDiffSignature(diff: GitDiffResponse) { @@ -2437,6 +2511,9 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const [activeDiffView, setActiveDiffView] = useState("workingTree"); const [reviewMode, setReviewMode] = useState("changes"); const [historyCommits, setHistoryCommits] = useState([]); + const [historyGraphState, setHistoryGraphState] = useState( + EMPTY_GIT_HISTORY_GRAPH_STATE, + ); const [historyLoading, setHistoryLoading] = useState(false); const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); @@ -2804,152 +2881,24 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel [cwd, gitClient, t], ); - const loadHistory = useCallback(async (options: GitRefreshOptions = {}) => { - const append = options.append === true && historyCommitsRef.current.length > 0; - if (append && !historyHasMoreRef.current) { - return; - } - if (historyInFlightRef.current) { - return; - } - historyInFlightRef.current = true; - const silent = options.silent === true; - const force = options.force !== false; - const skip = append ? historyCommitsRef.current.length : 0; - if (!gitClient || !cwd.trim()) { - historySignatureRef.current = ""; - historyCommitsRef.current = []; - setHistoryCommits([]); - selectedCommitShaRef.current = ""; - selectedCommitFilePathRef.current = ""; - expandedCommitShasRef.current = new Set(); - setSelectedCommitSha(""); - setSelectedCommitFilePath(""); - setExpandedCommitShas(new Set()); - setHistoryHasMoreValue(false); - setHistoryLoadMoreError(""); - setHistoryLoading(false); - setHistoryLoadingMore(false); - clearCommitDiff(); - setHistoryError(""); - historyInFlightRef.current = false; - return; - } - if (append) { - setHistoryLoadingMore(true); - setHistoryLoadMoreError(""); - } else if (!silent) { - setHistoryLoading(true); - setHistoryError(""); - setHistoryLoadMoreError(""); - } - try { - const response = await gitClient.log(cwd, { - limit: GIT_HISTORY_PAGE_SIZE, - skip, - }); - const previousStatusSignature = statusSignatureRef.current; - const nextStatusSignature = gitRepositoryStateSignature(response.state); - const statusChanged = previousStatusSignature !== nextStatusSignature; - statusSignatureRef.current = nextStatusSignature; - const pageHasMore = response.commits.length >= GIT_HISTORY_PAGE_SIZE; - if (append) { - setState(response.state); - if (response.state.status !== "ready") { - historySignatureRef.current = ""; - historyCommitsRef.current = []; - setHistoryCommits([]); - selectedCommitShaRef.current = ""; - selectedCommitFilePathRef.current = ""; - expandedCommitShasRef.current = new Set(); - setSelectedCommitSha(""); - setSelectedCommitFilePath(""); - setExpandedCommitShas(new Set()); - setHistoryHasMoreValue(false); - clearCommitDiff(); - return; - } - const existingCommits = historyCommitsRef.current; - const existingShas = new Set(existingCommits.map((commit) => commit.sha)); - const nextCommits = [ - ...existingCommits, - ...response.commits.filter((commit) => !existingShas.has(commit.sha)), - ]; - historyCommitsRef.current = nextCommits; - setHistoryCommits(nextCommits); - setHistoryHasMoreValue(pageHasMore); - setHistoryLoadMoreError(""); + const loadHistory = useCallback( + async (options: GitRefreshOptions = {}) => { + const append = options.append === true && historyCommitsRef.current.length > 0; + if (append && !historyHasMoreRef.current) { return; } - const nextSignature = gitHistorySignature(response.state, response.commits); - const historyChanged = historySignatureRef.current !== nextSignature; - historySignatureRef.current = nextSignature; - if (historyChanged) { - commitDetailsCacheRef.current.clear(); - } - if (options.notifyChanged && previousStatusSignature && statusChanged) { - suppressNextGitChangedRef.current = true; - dispatchGitChanged(cwd); - } - setHistoryHasMoreValue( - !force && - !historyChanged && - historyCommitsRef.current.length > response.commits.length && - !historyHasMoreRef.current - ? false - : pageHasMore, - ); - setHistoryLoadMoreError(""); - if (!force && !historyChanged) { + if (historyInFlightRef.current) { return; } - setState(response.state); - historyCommitsRef.current = response.commits; - setHistoryCommits(response.commits); - if (response.state.status !== "ready" || response.commits.length === 0) { - selectedCommitShaRef.current = ""; - selectedCommitFilePathRef.current = ""; - expandedCommitShasRef.current = new Set(); - setSelectedCommitSha(""); - setSelectedCommitFilePath(""); - setExpandedCommitShas(new Set()); - setHistoryHasMoreValue(false); - clearCommitDiff(); - return; - } - const currentCommit = response.commits.find( - (commit) => commit.sha === selectedCommitShaRef.current, - ); - const nextCommit = currentCommit ?? response.commits[0]; - const currentFile = - currentCommit?.files.find((file) => file.path === selectedCommitFilePathRef.current) ?? - null; - const availableCommitShas = new Set(response.commits.map((commit) => commit.sha)); - const nextExpandedCommitShas = new Set( - [...expandedCommitShasRef.current].filter((sha) => availableCommitShas.has(sha)), - ); - if (currentCommit && currentFile) { - nextExpandedCommitShas.add(currentCommit.sha); - } - selectedCommitShaRef.current = nextCommit.sha; - selectedCommitFilePathRef.current = currentFile?.path ?? ""; - expandedCommitShasRef.current = nextExpandedCommitShas; - setSelectedCommitSha(nextCommit.sha); - setSelectedCommitFilePath(currentFile?.path ?? ""); - setExpandedCommitShas(nextExpandedCommitShas); - if (currentCommit && currentFile) { - void loadCommitDiff(currentCommit.sha, currentFile.path); - } else { - clearCommitDiff(); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (append) { - setHistoryLoadMoreError(message); - setHistoryHasMoreValue(true); - } else if (!silent || force) { + historyInFlightRef.current = true; + const silent = options.silent === true; + const force = options.force !== false; + const skip = append ? historyCommitsRef.current.length : 0; + if (!gitClient || !cwd.trim()) { + historySignatureRef.current = ""; historyCommitsRef.current = []; setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; expandedCommitShasRef.current = new Set(); @@ -2958,18 +2907,160 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel setExpandedCommitShas(new Set()); setHistoryHasMoreValue(false); setHistoryLoadMoreError(""); + setHistoryLoading(false); + setHistoryLoadingMore(false); clearCommitDiff(); - setHistoryError(message); + setHistoryError(""); + historyInFlightRef.current = false; + return; } - } finally { - historyInFlightRef.current = false; if (append) { - setHistoryLoadingMore(false); + setHistoryLoadingMore(true); + setHistoryLoadMoreError(""); } else if (!silent) { - setHistoryLoading(false); + setHistoryLoading(true); + setHistoryError(""); + setHistoryLoadMoreError(""); } - } - }, [clearCommitDiff, cwd, gitClient, loadCommitDiff, setHistoryHasMoreValue]); + try { + const response = await gitClient.log(cwd, { + limit: GIT_HISTORY_PAGE_SIZE, + skip, + }); + const nextHistoryGraphState = gitHistoryGraphStateFromResponse(response); + const previousStatusSignature = statusSignatureRef.current; + const nextStatusSignature = gitRepositoryStateSignature(response.state); + const statusChanged = previousStatusSignature !== nextStatusSignature; + statusSignatureRef.current = nextStatusSignature; + const pageHasMore = response.commits.length >= GIT_HISTORY_PAGE_SIZE; + if (append) { + setState(response.state); + if (response.state.status !== "ready") { + historySignatureRef.current = ""; + historyCommitsRef.current = []; + setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); + selectedCommitShaRef.current = ""; + selectedCommitFilePathRef.current = ""; + expandedCommitShasRef.current = new Set(); + setSelectedCommitSha(""); + setSelectedCommitFilePath(""); + setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + clearCommitDiff(); + return; + } + const existingCommits = historyCommitsRef.current; + const existingShas = new Set(existingCommits.map((commit) => commit.sha)); + const nextCommits = [ + ...existingCommits, + ...response.commits.filter((commit) => !existingShas.has(commit.sha)), + ]; + historyCommitsRef.current = nextCommits; + setHistoryCommits(nextCommits); + setHistoryGraphState(nextHistoryGraphState); + setHistoryHasMoreValue(pageHasMore); + setHistoryLoadMoreError(""); + return; + } + + const nextSignature = gitHistorySignature( + response.state, + response.commits, + nextHistoryGraphState, + ); + const historyChanged = historySignatureRef.current !== nextSignature; + historySignatureRef.current = nextSignature; + if (historyChanged) { + commitDetailsCacheRef.current.clear(); + } + if (options.notifyChanged && previousStatusSignature && statusChanged) { + suppressNextGitChangedRef.current = true; + dispatchGitChanged(cwd); + } + setHistoryHasMoreValue( + !force && + !historyChanged && + historyCommitsRef.current.length > response.commits.length && + !historyHasMoreRef.current + ? false + : pageHasMore, + ); + setHistoryLoadMoreError(""); + if (!force && !historyChanged) { + return; + } + setState(response.state); + historyCommitsRef.current = response.commits; + setHistoryCommits(response.commits); + setHistoryGraphState(nextHistoryGraphState); + if (response.state.status !== "ready" || response.commits.length === 0) { + selectedCommitShaRef.current = ""; + selectedCommitFilePathRef.current = ""; + expandedCommitShasRef.current = new Set(); + setSelectedCommitSha(""); + setSelectedCommitFilePath(""); + setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + clearCommitDiff(); + return; + } + const currentCommit = response.commits.find( + (commit) => commit.sha === selectedCommitShaRef.current, + ); + const nextCommit = currentCommit ?? response.commits[0]; + const currentFile = + currentCommit?.files.find((file) => file.path === selectedCommitFilePathRef.current) ?? + null; + const availableCommitShas = new Set(response.commits.map((commit) => commit.sha)); + const nextExpandedCommitShas = new Set( + [...expandedCommitShasRef.current].filter((sha) => availableCommitShas.has(sha)), + ); + if (currentCommit && currentFile) { + nextExpandedCommitShas.add(currentCommit.sha); + } + selectedCommitShaRef.current = nextCommit.sha; + selectedCommitFilePathRef.current = currentFile?.path ?? ""; + expandedCommitShasRef.current = nextExpandedCommitShas; + setSelectedCommitSha(nextCommit.sha); + setSelectedCommitFilePath(currentFile?.path ?? ""); + setExpandedCommitShas(nextExpandedCommitShas); + if (currentCommit && currentFile) { + void loadCommitDiff(currentCommit.sha, currentFile.path); + } else { + clearCommitDiff(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (append) { + setHistoryLoadMoreError(message); + setHistoryHasMoreValue(true); + } else if (!silent || force) { + historyCommitsRef.current = []; + setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); + selectedCommitShaRef.current = ""; + selectedCommitFilePathRef.current = ""; + expandedCommitShasRef.current = new Set(); + setSelectedCommitSha(""); + setSelectedCommitFilePath(""); + setExpandedCommitShas(new Set()); + setHistoryHasMoreValue(false); + setHistoryLoadMoreError(""); + clearCommitDiff(); + setHistoryError(message); + } + } finally { + historyInFlightRef.current = false; + if (append) { + setHistoryLoadingMore(false); + } else if (!silent) { + setHistoryLoading(false); + } + } + }, + [clearCommitDiff, cwd, gitClient, loadCommitDiff, setHistoryHasMoreValue], + ); const maybeLoadMoreHistory = useCallback( (element: HTMLElement | null) => { @@ -3317,18 +3408,32 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel () => computeGitGraph(historyCommits, { currentRef: state.head, - remoteRef: state.upstream, + remoteRef: historyGraphState.historyBaseRef, remoteName: state.remoteName, + showRemoteChangeMarkers: true, + ahead: historyGraphState.historyAhead, + behind: historyGraphState.historyBehind, + mergeBase: historyGraphState.mergeBase, }), - [historyCommits, state.head, state.remoteName, state.upstream], + [historyCommits, historyGraphState, state.head, state.remoteName], + ); + const historyCommitBySha = useMemo( + () => new Map(historyCommits.map((commit) => [commit.sha, commit])), + [historyCommits], ); const historyRows = useMemo(() => { const rows: GitHistoryRow[] = []; - historyCommits.forEach((commit, commitIndex) => { - rows.push({ type: "commit", commit, commitIndex }); + gitGraph.rows.forEach((graphRow, graphIndex) => { + if (graphRow.kind === "incoming-changes" || graphRow.kind === "outgoing-changes") { + rows.push({ type: "marker", kind: graphRow.kind, graphIndex }); + return; + } + const commit = historyCommitBySha.get(graphRow.sha); + if (!commit) return; + rows.push({ type: "commit", commit, graphIndex }); if (expandedCommitShas.has(commit.sha)) { commit.files.forEach((file) => { - rows.push({ type: "file", commit, commitIndex, file }); + rows.push({ type: "file", commit, graphIndex, file }); }); } }); @@ -3336,7 +3441,14 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel rows.push({ type: "loadMore" }); } return rows; - }, [expandedCommitShas, historyCommits, historyHasMore, historyLoadMoreError, historyLoadingMore]); + }, [ + expandedCommitShas, + gitGraph.rows, + historyCommitBySha, + historyHasMore, + historyLoadMoreError, + historyLoadingMore, + ]); const historyVirtualizer = useVirtualizer({ count: historyRows.length, getScrollElement: () => historyListRef.current, @@ -3345,6 +3457,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel getItemKey: (index) => { const row = historyRows[index]; if (!row) return index; + if (row.type === "marker") return `marker:${row.kind}:${row.graphIndex}`; if (row.type === "commit") return `commit:${row.commit.sha}`; if (row.type === "loadMore") return "load-more"; return `file:${row.commit.sha}:${row.file.status}:${row.file.oldPath ?? ""}:${row.file.path}`; @@ -4425,6 +4538,45 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel

); } + if (row.type === "marker") { + const graphRow = gitGraph.rows[row.graphIndex]; + if (!graphRow) return null; + const label = + row.kind === "outgoing-changes" + ? t("projectTools.gitReview.outgoingChanges") + : t("projectTools.gitReview.incomingChanges"); + const refLabel = gitHistoryMarkerRef( + row.kind, + state, + historyGraphState.historyBaseRef, + ); + const title = refLabel ? `${label} ${refLabel}` : label; + return ( +
+
+ + + {label} + + {refLabel ? ( + + {refLabel} + + ) : null} +
+
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -4437,7 +4589,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const filePath = row.file.oldPath ? `${parentPath(row.file.oldPath)} -> ${parentPath(row.file.path)}` : parentPath(row.file.path); - const graphRow = gitGraph.rows[row.commitIndex]; + const graphRow = gitGraph.rows[row.graphIndex]; return (
> = { "projectTools.gitReview.labelBase": "基线", "projectTools.gitReview.labelAhead": "领先", "projectTools.gitReview.labelBehind": "落后", + "projectTools.gitReview.outgoingChanges": "传出的更改", + "projectTools.gitReview.incomingChanges": "传入的更改", "projectTools.gitReview.labelStaged": "已暂存", "projectTools.gitReview.labelUnstaged": "未暂存", "projectTools.gitReview.labelUntracked": "未跟踪", @@ -1474,6 +1476,8 @@ export const translations: Record> = { "projectTools.gitReview.labelBase": "Base", "projectTools.gitReview.labelAhead": "Ahead", "projectTools.gitReview.labelBehind": "Behind", + "projectTools.gitReview.outgoingChanges": "Outgoing Changes", + "projectTools.gitReview.incomingChanges": "Incoming Changes", "projectTools.gitReview.labelStaged": "Staged", "projectTools.gitReview.labelUnstaged": "Unstaged", "projectTools.gitReview.labelUntracked": "Untracked", diff --git a/crates/agent-gateway/web/src/lib/git/gitGraph.ts b/crates/agent-gateway/web/src/lib/git/gitGraph.ts index 6b82fe3ac..9eafd40a0 100644 --- a/crates/agent-gateway/web/src/lib/git/gitGraph.ts +++ b/crates/agent-gateway/web/src/lib/git/gitGraph.ts @@ -12,7 +12,11 @@ export const GRAPH_REF_COLORS = { base: "var(--git-review-graph-ref-base)", } as const; +export const GIT_GRAPH_INCOMING_CHANGES_ID = "scm-graph-incoming-changes"; +export const GIT_GRAPH_OUTGOING_CHANGES_ID = "scm-graph-outgoing-changes"; + export type GraphColor = number | string; +export type GraphRowKind = "commit" | "incoming-changes" | "outgoing-changes"; export type GraphLane = { id: string; @@ -20,6 +24,7 @@ export type GraphLane = { }; export type GraphRow = { + kind: GraphRowKind; sha: string; parents: string[]; commitCol: number; @@ -41,6 +46,10 @@ export type GitGraphOptions = { remoteRef?: string; baseRef?: string; remoteName?: string; + showRemoteChangeMarkers?: boolean; + ahead?: number; + behind?: number; + mergeBase?: string; }; function cloneLane(lane: GraphLane): GraphLane { @@ -94,6 +103,19 @@ function labelColorForCommit( return undefined; } +function commitHasRef(commit: GitGraphCommit, ref: string) { + const normalizedRef = normalizeRef(ref); + if (!normalizedRef) return false; + return (commit.refs ?? []).some((rawRef) => normalizeRef(rawRef) === normalizedRef); +} + +function findCommitShaForRef(commits: readonly GitGraphCommit[], ref: string) { + for (const commit of commits) { + if (commitHasRef(commit, ref)) return commit.sha; + } + return ""; +} + function uniqueParents(parents: readonly string[]) { const seen = new Set(); const result: string[] = []; @@ -106,6 +128,175 @@ function uniqueParents(parents: readonly string[]) { return result; } +function inferCommonAncestorSha( + commits: readonly GitGraphCommit[], + currentSha: string, + remoteSha: string, +) { + const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); + + function collectReachable(startSha: string) { + const reachable = new Set(); + const stack = [startSha]; + while (stack.length > 0) { + const sha = stack.pop() ?? ""; + if (!sha || reachable.has(sha)) continue; + reachable.add(sha); + const commit = commitBySha.get(sha); + if (!commit) continue; + for (const parent of uniqueParents(commit.parents)) { + stack.push(parent); + } + } + return reachable; + } + + const currentReachable = collectReachable(currentSha); + const remoteReachable = collectReachable(remoteSha); + for (const commit of commits) { + if (currentReachable.has(commit.sha) && remoteReachable.has(commit.sha)) { + return commit.sha; + } + } + return ""; +} + +function findLastGraphRowIndex(rows: readonly GraphRow[], predicate: (row: GraphRow) => boolean) { + for (let index = rows.length - 1; index >= 0; index--) { + if (predicate(rows[index])) return index; + } + return -1; +} + +function createSyntheticGraphRow({ + kind, + sha, + parents, + inputLanes, + outputLanes, + color, +}: { + kind: "incoming-changes" | "outgoing-changes"; + sha: string; + parents: string[]; + inputLanes: GraphLane[]; + outputLanes: GraphLane[]; + color: GraphColor; +}): GraphRow { + const inputIndex = inputLanes.findIndex((lane) => lane.id === sha); + return { + kind, + sha, + parents, + commitCol: inputIndex >= 0 ? inputIndex : inputLanes.length, + commitColor: color, + inputLanes, + outputLanes, + isHead: false, + isMerge: false, + }; +} + +function shouldShowChangeMarker(count: number | undefined) { + return count === undefined || count > 0; +} + +function addIncomingOutgoingChangeRows( + rows: GraphRow[], + commits: readonly GitGraphCommit[], + options: GitGraphOptions, +) { + const currentSha = findCommitShaForRef(commits, options.currentRef ?? ""); + const remoteSha = findCommitShaForRef(commits, options.remoteRef ?? ""); + if (!currentSha || !remoteSha || currentSha === remoteSha) return; + + const mergeBase = + options.mergeBase?.trim() || inferCommonAncestorSha(commits, currentSha, remoteSha); + if (!mergeBase) return; + + if ( + shouldShowChangeMarker(options.behind) && + remoteSha !== mergeBase && + rows.some((row) => row.sha === mergeBase) + ) { + const beforeIndex = findLastGraphRowIndex(rows, (row) => + row.outputLanes.some((lane) => lane.id === mergeBase), + ); + const afterIndex = rows.findIndex((row) => row.kind === "commit" && row.sha === mergeBase); + + if (beforeIndex !== -1 && afterIndex !== -1) { + const incomingChangeMerged = + rows[beforeIndex].parents.length === 2 && rows[beforeIndex].parents.includes(mergeBase); + + if (!incomingChangeMerged) { + rows[beforeIndex] = { + ...rows[beforeIndex], + inputLanes: rows[beforeIndex].inputLanes.map((lane) => + lane.id === mergeBase && lane.color === GRAPH_REF_COLORS.remote + ? { ...lane, id: GIT_GRAPH_INCOMING_CHANGES_ID } + : cloneLane(lane), + ), + outputLanes: rows[beforeIndex].outputLanes.map((lane) => + lane.id === mergeBase && lane.color === GRAPH_REF_COLORS.remote + ? { ...lane, id: GIT_GRAPH_INCOMING_CHANGES_ID } + : cloneLane(lane), + ), + }; + + rows.splice( + afterIndex, + 0, + createSyntheticGraphRow({ + kind: "incoming-changes", + sha: GIT_GRAPH_INCOMING_CHANGES_ID, + parents: [mergeBase], + inputLanes: rows[beforeIndex].outputLanes.map(cloneLane), + outputLanes: rows[afterIndex].inputLanes.map(cloneLane), + color: GRAPH_REF_COLORS.remote, + }), + ); + } + } + } + + if (shouldShowChangeMarker(options.ahead) && currentSha !== mergeBase) { + const currentIndex = rows.findIndex((row) => row.kind === "commit" && row.sha === currentSha); + if (currentIndex !== -1) { + const inputLanes = rows[currentIndex].inputLanes.map(cloneLane); + rows.splice( + currentIndex, + 0, + createSyntheticGraphRow({ + kind: "outgoing-changes", + sha: GIT_GRAPH_OUTGOING_CHANGES_ID, + parents: [currentSha], + inputLanes, + outputLanes: [ + ...inputLanes.map(cloneLane), + { id: currentSha, color: GRAPH_REF_COLORS.local }, + ], + color: GRAPH_REF_COLORS.local, + }), + ); + rows[currentIndex + 1] = { + ...rows[currentIndex + 1], + inputLanes: [ + ...rows[currentIndex + 1].inputLanes.map(cloneLane), + { id: currentSha, color: GRAPH_REF_COLORS.local }, + ], + }; + } + } +} + +function graphColumnCount(row: GraphRow) { + return Math.max(row.inputLanes.length, row.outputLanes.length, row.commitCol + 1, 1); +} + +function calculateMaxCols(rows: readonly GraphRow[]) { + return rows.reduce((maxCols, row) => Math.max(maxCols, graphColumnCount(row)), 0); +} + export function computeGitGraph( commits: readonly GitGraphCommit[], options: GitGraphOptions = {}, @@ -118,6 +309,7 @@ export function computeGitGraph( const rows: GraphRow[] = []; const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); const refColorMap = createRefColorMap(options); + const currentHeadSha = findCommitShaForRef(commits, options.currentRef ?? ""); let nextColor = -1; let previousOutputLanes: GraphLane[] = []; let maxCols = 1; @@ -166,17 +358,23 @@ export function computeGitGraph( maxCols = Math.max(maxCols, inputLanes.length, outputLanes.length, commitCol + 1); rows.push({ + kind: "commit", sha: commit.sha, parents, commitCol, commitColor, inputLanes, outputLanes, - isHead: index === 0, + isHead: currentHeadSha ? commit.sha === currentHeadSha : index === 0, isMerge: parents.length > 1, }); previousOutputLanes = outputLanes; } + if (options.showRemoteChangeMarkers) { + addIncomingOutgoingChangeRows(rows, commits, options); + maxCols = Math.max(maxCols, calculateMaxCols(rows)); + } + return { rows, maxCols }; } diff --git a/crates/agent-gateway/web/src/lib/git/types.ts b/crates/agent-gateway/web/src/lib/git/types.ts index 1cb9da8c3..6d33fd9d3 100644 --- a/crates/agent-gateway/web/src/lib/git/types.ts +++ b/crates/agent-gateway/web/src/lib/git/types.ts @@ -81,6 +81,10 @@ export type GitCommitSummary = { export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; + historyBaseRef: string; + historyAhead: number; + historyBehind: number; + mergeBase: string; }; export type GitCommitDetails = { @@ -285,6 +289,10 @@ export function normalizeGitLogResponse(input: unknown, workdir = ""): GitLogRes return { state: normalizeGitRepositoryState(source.state, workdir), commits: Array.isArray(source.commits) ? source.commits.map(normalizeGitCommitSummary) : [], + historyBaseRef: asString(source.historyBaseRef ?? source.history_base_ref), + historyAhead: asNumber(source.historyAhead ?? source.history_ahead), + historyBehind: asNumber(source.historyBehind ?? source.history_behind), + mergeBase: asString(source.mergeBase ?? source.merge_base), }; } diff --git a/crates/agent-gui/src-tauri/src/commands/git.rs b/crates/agent-gui/src-tauri/src/commands/git.rs index 0b7ddbfc3..24da88c03 100644 --- a/crates/agent-gui/src-tauri/src/commands/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/git.rs @@ -123,6 +123,10 @@ pub struct GitCommitSummary { pub struct GitLogResponse { pub state: GitRepositoryState, pub commits: Vec, + pub history_base_ref: String, + pub history_ahead: i32, + pub history_behind: i32, + pub merge_base: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1379,6 +1383,49 @@ fn local_only_commit_shas(repo_root: &str, cloud_ref: &str) -> HashSet { .unwrap_or_default() } +fn resolve_history_base_ref(state: &GitRepositoryState) -> String { + let cloud_ref = resolve_cloud_tracking_ref(state); + if !cloud_ref.trim().is_empty() { + return cloud_ref; + } + resolve_review_base(state) +} + +fn resolve_history_merge_base(repo_root: &str, history_base_ref: &str) -> String { + if history_base_ref.trim().is_empty() { + return String::new(); + } + git_success(repo_root, &["merge-base", "HEAD", history_base_ref]) + .map(|output| { + output + .stdout + .lines() + .next() + .unwrap_or("") + .trim() + .to_string() + }) + .unwrap_or_default() +} + +fn history_ahead_behind(repo_root: &str, history_base_ref: &str) -> (i32, i32) { + if history_base_ref.trim().is_empty() { + return (0, 0); + } + let rev_range = format!("HEAD...{history_base_ref}"); + git_success( + repo_root, + &["rev-list", "--left-right", "--count", &rev_range], + ) + .map(|output| { + let mut counts = output.stdout.split_whitespace(); + let ahead = counts.next().and_then(|raw| raw.parse().ok()).unwrap_or(0); + let behind = counts.next().and_then(|raw| raw.parse().ok()).unwrap_or(0); + (ahead, behind) + }) + .unwrap_or_default() +} + fn parse_shortstat_count(segment: &str) -> usize { segment .split_whitespace() @@ -1428,12 +1475,20 @@ pub(crate) fn git_log_sync( return Ok(GitLogResponse { state, commits: Vec::new(), + history_base_ref: String::new(), + history_ahead: 0, + history_behind: 0, + merge_base: String::new(), }); } if !ref_exists(&state.repo_root, "HEAD") { return Ok(GitLogResponse { state, commits: Vec::new(), + history_base_ref: String::new(), + history_ahead: 0, + history_behind: 0, + merge_base: String::new(), }); } let limit = limit @@ -1459,13 +1514,9 @@ pub(crate) fn git_log_sync( "--pretty=format:%x1e%H%x1f%h%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string(), "HEAD".to_string(), ]); - let review_ref = if !state.upstream.trim().is_empty() { - state.upstream.clone() - } else { - resolve_review_base(&state) - }; + let review_ref = resolve_history_base_ref(&state); if !review_ref.trim().is_empty() && review_ref != "HEAD" { - args.push(review_ref); + args.push(review_ref.clone()); } let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let output = git_success(&state.repo_root, &arg_refs)?; @@ -1494,7 +1545,16 @@ pub(crate) fn git_log_sync( commit.local_only = local_only_shas.contains(&commit.sha); } } - Ok(GitLogResponse { state, commits }) + let merge_base = resolve_history_merge_base(&state.repo_root, &review_ref); + let (history_ahead, history_behind) = history_ahead_behind(&state.repo_root, &review_ref); + Ok(GitLogResponse { + state, + commits, + history_base_ref: review_ref, + history_ahead, + history_behind, + merge_base, + }) } pub(crate) fn git_commit_details_sync( @@ -3013,6 +3073,52 @@ mod tests { ); } + #[test] + fn git_log_uses_local_branch_fallback_for_history_graph_when_upstream_missing() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let initial = git_status_sync(workdir.clone()).expect("initial status"); + let initial_sha = git_success(&workdir, &["rev-parse", initial.head.as_str()]) + .expect("read initial branch sha") + .stdout + .trim() + .to_string(); + + run_temp_git(repo.path(), &["checkout", "-b", "feature/history-graph"]); + fs::write(repo.path().join("feature-history.txt"), "feature\n") + .expect("write feature file"); + run_temp_git(repo.path(), &["add", "feature-history.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "feature history graph"]); + + run_temp_git(repo.path(), &["checkout", initial.head.as_str()]); + fs::write(repo.path().join("main-history.txt"), "main\n").expect("write main file"); + run_temp_git(repo.path(), &["add", "main-history.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "main history graph"]); + + run_temp_git(repo.path(), &["checkout", "feature/history-graph"]); + let history = git_log_sync(workdir, Some(10), None).expect("git log"); + assert!( + history.state.upstream.trim().is_empty(), + "test branch should not have upstream: {}", + history.state.upstream + ); + assert_eq!(history.history_base_ref, initial.head); + assert_eq!(history.history_ahead, 1); + assert_eq!(history.history_behind, 1); + assert_eq!(history.merge_base, initial_sha); + let subjects = history + .commits + .iter() + .map(|commit| commit.subject.as_str()) + .collect::>(); + assert!( + subjects.contains(&"feature history graph") && subjects.contains(&"main history graph"), + "history subjects should include both sides of the fallback comparison: {subjects:?}" + ); + } + #[test] fn git_create_branch_can_start_from_commit() { let Some(repo) = init_temp_repo() else { @@ -3284,7 +3390,16 @@ mod tests { run_temp_git(repo.path(), &["add", "feature.txt"]); run_temp_git(repo.path(), &["commit", "-m", "feature local only"]); + let remote_feature_sha = git_success(&workdir, &["rev-parse", "origin/feature/local-only"]) + .expect("read remote feature branch sha") + .stdout + .trim() + .to_string(); let history = git_log_sync(workdir, Some(10), None).expect("git log"); + assert_eq!(history.history_base_ref, "origin/feature/local-only"); + assert_eq!(history.history_ahead, 1); + assert_eq!(history.history_behind, 0); + assert_eq!(history.merge_base, remote_feature_sha); let local_commit = history .commits .iter() diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx index 9161995a2..fde0caa21 100644 --- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx @@ -25,6 +25,7 @@ import type { GitCommitFile, GitCommitSummary, GitDiffResponse, + GitLogResponse, GitOperationResponse, GitRepositoryState, GitStatusEntry, @@ -521,16 +522,26 @@ type ParsedDiffStat = { type DiffViewKind = "branch" | "workingTree"; type GitReviewMode = "changes" | "history"; type GitReviewStackedPane = "list" | "detail"; +type GitHistoryMarkerKind = Extract; +type GitHistoryGraphState = Pick< + GitLogResponse, + "historyBaseRef" | "historyAhead" | "historyBehind" | "mergeBase" +>; type GitHistoryRow = + | { + type: "marker"; + kind: GitHistoryMarkerKind; + graphIndex: number; + } | { type: "commit"; commit: GitCommitSummary; - commitIndex: number; + graphIndex: number; } | { type: "file"; commit: GitCommitSummary; - commitIndex: number; + graphIndex: number; file: GitCommitFile; } | { @@ -2222,7 +2233,11 @@ function gitRepositoryStateSignature(state: GitRepositoryState) { return `${header}\x1d${entries}`; } -function gitHistorySignature(state: GitRepositoryState, commits: GitCommitSummary[]) { +function gitHistorySignature( + state: GitRepositoryState, + commits: GitCommitSummary[], + historyGraphState: GitHistoryGraphState, +) { const commitsSignature = commits .map((commit) => [ @@ -2239,7 +2254,7 @@ function gitHistorySignature(state: GitRepositoryState, commits: GitCommitSummar ].join("\x1e"), ) .join("\x1f"); - return `${gitRepositoryStateSignature(state)}\x1d${commitsSignature}`; + return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; } function gitDiffSignature(diff: GitDiffResponse) { @@ -2362,6 +2377,30 @@ function commitHistoryTitle(commit: GitCommitSummary) { return refs.length > 0 ? `${label} - ${refs.join(", ")}` : label; } +function gitHistoryMarkerRef( + kind: GitHistoryMarkerKind, + state: Pick, + historyBaseRef: string, +) { + return kind === "outgoing-changes" ? state.head : historyBaseRef; +} + +const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { + historyBaseRef: "", + historyAhead: 0, + historyBehind: 0, + mergeBase: "", +}; + +function gitHistoryGraphStateFromResponse(response: GitLogResponse): GitHistoryGraphState { + return { + historyBaseRef: response.historyBaseRef, + historyAhead: response.historyAhead, + historyBehind: response.historyBehind, + mergeBase: response.mergeBase, + }; +} + function CommitRefTags({ refs, selected, @@ -2418,14 +2457,48 @@ function CommitRefTags({ function GitGraphCommitMarker({ cx, color, + kind, isHead, isMerge, }: { cx: number; color: string; + kind: GraphRow["kind"]; isHead: boolean; isMerge: boolean; }) { + if (kind === "incoming-changes" || kind === "outgoing-changes") { + return ( + + + + + + ); + } + if (isHead) { return ( @@ -2617,6 +2690,7 @@ function GitGraphSvgCell({ row }: { row: GraphRow }) { @@ -2756,6 +2830,9 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const [activeDiffView, setActiveDiffView] = useState("workingTree"); const [reviewMode, setReviewMode] = useState("changes"); const [historyCommits, setHistoryCommits] = useState([]); + const [historyGraphState, setHistoryGraphState] = useState( + EMPTY_GIT_HISTORY_GRAPH_STATE, + ); const [historyLoading, setHistoryLoading] = useState(false); const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); @@ -3153,6 +3230,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel historySignatureRef.current = ""; historyCommitsRef.current = []; setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; expandedCommitShasRef.current = new Set(); @@ -3181,6 +3259,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel limit: GIT_HISTORY_PAGE_SIZE, skip, }); + const nextHistoryGraphState = gitHistoryGraphStateFromResponse(response); const pageHasMore = response.commits.length >= GIT_HISTORY_PAGE_SIZE; const previousStatusSignature = statusSignatureRef.current; const nextStatusSignature = gitRepositoryStateSignature(response.state); @@ -3193,6 +3272,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel historySignatureRef.current = ""; historyCommitsRef.current = []; setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; expandedCommitShasRef.current = new Set(); @@ -3211,12 +3291,17 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel ]; historyCommitsRef.current = nextCommits; setHistoryCommits(nextCommits); + setHistoryGraphState(nextHistoryGraphState); setHistoryHasMoreValue(pageHasMore); setHistoryLoadMoreError(""); return; } - const nextSignature = gitHistorySignature(response.state, response.commits); + const nextSignature = gitHistorySignature( + response.state, + response.commits, + nextHistoryGraphState, + ); const historyChanged = historySignatureRef.current !== nextSignature; historySignatureRef.current = nextSignature; if (historyChanged) { @@ -3241,6 +3326,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel setState(response.state); historyCommitsRef.current = response.commits; setHistoryCommits(response.commits); + setHistoryGraphState(nextHistoryGraphState); if (response.state.status !== "ready" || response.commits.length === 0) { selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; @@ -3285,6 +3371,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel } else if (!silent || force) { historyCommitsRef.current = []; setHistoryCommits([]); + setHistoryGraphState(EMPTY_GIT_HISTORY_GRAPH_STATE); selectedCommitShaRef.current = ""; selectedCommitFilePathRef.current = ""; expandedCommitShasRef.current = new Set(); @@ -3656,18 +3743,32 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel () => computeGitGraph(historyCommits, { currentRef: state.head, - remoteRef: state.upstream, + remoteRef: historyGraphState.historyBaseRef, remoteName: state.remoteName, + showRemoteChangeMarkers: true, + ahead: historyGraphState.historyAhead, + behind: historyGraphState.historyBehind, + mergeBase: historyGraphState.mergeBase, }), - [historyCommits, state.head, state.remoteName, state.upstream], + [historyCommits, historyGraphState, state.head, state.remoteName], + ); + const historyCommitBySha = useMemo( + () => new Map(historyCommits.map((commit) => [commit.sha, commit])), + [historyCommits], ); const historyRows = useMemo(() => { const rows: GitHistoryRow[] = []; - historyCommits.forEach((commit, commitIndex) => { - rows.push({ type: "commit", commit, commitIndex }); + gitGraph.rows.forEach((graphRow, graphIndex) => { + if (graphRow.kind === "incoming-changes" || graphRow.kind === "outgoing-changes") { + rows.push({ type: "marker", kind: graphRow.kind, graphIndex }); + return; + } + const commit = historyCommitBySha.get(graphRow.sha); + if (!commit) return; + rows.push({ type: "commit", commit, graphIndex }); if (expandedCommitShas.has(commit.sha)) { commit.files.forEach((file) => { - rows.push({ type: "file", commit, commitIndex, file }); + rows.push({ type: "file", commit, graphIndex, file }); }); } }); @@ -3675,7 +3776,14 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel rows.push({ type: "loadMore" }); } return rows; - }, [expandedCommitShas, historyCommits, historyHasMore, historyLoadMoreError, historyLoadingMore]); + }, [ + expandedCommitShas, + gitGraph.rows, + historyCommitBySha, + historyHasMore, + historyLoadMoreError, + historyLoadingMore, + ]); const historyVirtualizer = useVirtualizer({ count: historyRows.length, getScrollElement: () => historyListRef.current, @@ -3684,6 +3792,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel getItemKey: (index) => { const row = historyRows[index]; if (!row) return index; + if (row.type === "marker") return `marker:${row.kind}:${row.graphIndex}`; if (row.type === "commit") return `commit:${row.commit.sha}`; if (row.type === "loadMore") return "load-more"; return `file:${row.commit.sha}:${row.file.status}:${row.file.oldPath ?? ""}:${row.file.path}`; @@ -4810,6 +4919,45 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel
); } + if (row.type === "marker") { + const graphRow = gitGraph.rows[row.graphIndex]; + if (!graphRow) return null; + const label = + row.kind === "outgoing-changes" + ? t("projectTools.gitReview.outgoingChanges") + : t("projectTools.gitReview.incomingChanges"); + const refLabel = gitHistoryMarkerRef( + row.kind, + state, + historyGraphState.historyBaseRef, + ); + const title = refLabel ? `${label} ${refLabel}` : label; + return ( +
+
+ + + {label} + + {refLabel ? ( + + {refLabel} + + ) : null} +
+
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -4823,7 +4971,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const filePath = row.file.oldPath ? `${parentPath(row.file.oldPath)} -> ${parentPath(row.file.path)}` : parentPath(row.file.path); - const graphRow = gitGraph.rows[row.commitIndex]; + const graphRow = gitGraph.rows[row.graphIndex]; return (
> = { "projectTools.gitReview.labelBase": "基线", "projectTools.gitReview.labelAhead": "领先", "projectTools.gitReview.labelBehind": "落后", + "projectTools.gitReview.outgoingChanges": "传出的更改", + "projectTools.gitReview.incomingChanges": "传入的更改", "projectTools.gitReview.labelStaged": "已暂存", "projectTools.gitReview.labelUnstaged": "未暂存", "projectTools.gitReview.labelUntracked": "未跟踪", @@ -1528,6 +1530,8 @@ export const translations: Record> = { "projectTools.gitReview.labelBase": "Base", "projectTools.gitReview.labelAhead": "Ahead", "projectTools.gitReview.labelBehind": "Behind", + "projectTools.gitReview.outgoingChanges": "Outgoing Changes", + "projectTools.gitReview.incomingChanges": "Incoming Changes", "projectTools.gitReview.labelStaged": "Staged", "projectTools.gitReview.labelUnstaged": "Unstaged", "projectTools.gitReview.labelUntracked": "Untracked", diff --git a/crates/agent-gui/src/lib/git/gitGraph.ts b/crates/agent-gui/src/lib/git/gitGraph.ts index 6b82fe3ac..9eafd40a0 100644 --- a/crates/agent-gui/src/lib/git/gitGraph.ts +++ b/crates/agent-gui/src/lib/git/gitGraph.ts @@ -12,7 +12,11 @@ export const GRAPH_REF_COLORS = { base: "var(--git-review-graph-ref-base)", } as const; +export const GIT_GRAPH_INCOMING_CHANGES_ID = "scm-graph-incoming-changes"; +export const GIT_GRAPH_OUTGOING_CHANGES_ID = "scm-graph-outgoing-changes"; + export type GraphColor = number | string; +export type GraphRowKind = "commit" | "incoming-changes" | "outgoing-changes"; export type GraphLane = { id: string; @@ -20,6 +24,7 @@ export type GraphLane = { }; export type GraphRow = { + kind: GraphRowKind; sha: string; parents: string[]; commitCol: number; @@ -41,6 +46,10 @@ export type GitGraphOptions = { remoteRef?: string; baseRef?: string; remoteName?: string; + showRemoteChangeMarkers?: boolean; + ahead?: number; + behind?: number; + mergeBase?: string; }; function cloneLane(lane: GraphLane): GraphLane { @@ -94,6 +103,19 @@ function labelColorForCommit( return undefined; } +function commitHasRef(commit: GitGraphCommit, ref: string) { + const normalizedRef = normalizeRef(ref); + if (!normalizedRef) return false; + return (commit.refs ?? []).some((rawRef) => normalizeRef(rawRef) === normalizedRef); +} + +function findCommitShaForRef(commits: readonly GitGraphCommit[], ref: string) { + for (const commit of commits) { + if (commitHasRef(commit, ref)) return commit.sha; + } + return ""; +} + function uniqueParents(parents: readonly string[]) { const seen = new Set(); const result: string[] = []; @@ -106,6 +128,175 @@ function uniqueParents(parents: readonly string[]) { return result; } +function inferCommonAncestorSha( + commits: readonly GitGraphCommit[], + currentSha: string, + remoteSha: string, +) { + const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); + + function collectReachable(startSha: string) { + const reachable = new Set(); + const stack = [startSha]; + while (stack.length > 0) { + const sha = stack.pop() ?? ""; + if (!sha || reachable.has(sha)) continue; + reachable.add(sha); + const commit = commitBySha.get(sha); + if (!commit) continue; + for (const parent of uniqueParents(commit.parents)) { + stack.push(parent); + } + } + return reachable; + } + + const currentReachable = collectReachable(currentSha); + const remoteReachable = collectReachable(remoteSha); + for (const commit of commits) { + if (currentReachable.has(commit.sha) && remoteReachable.has(commit.sha)) { + return commit.sha; + } + } + return ""; +} + +function findLastGraphRowIndex(rows: readonly GraphRow[], predicate: (row: GraphRow) => boolean) { + for (let index = rows.length - 1; index >= 0; index--) { + if (predicate(rows[index])) return index; + } + return -1; +} + +function createSyntheticGraphRow({ + kind, + sha, + parents, + inputLanes, + outputLanes, + color, +}: { + kind: "incoming-changes" | "outgoing-changes"; + sha: string; + parents: string[]; + inputLanes: GraphLane[]; + outputLanes: GraphLane[]; + color: GraphColor; +}): GraphRow { + const inputIndex = inputLanes.findIndex((lane) => lane.id === sha); + return { + kind, + sha, + parents, + commitCol: inputIndex >= 0 ? inputIndex : inputLanes.length, + commitColor: color, + inputLanes, + outputLanes, + isHead: false, + isMerge: false, + }; +} + +function shouldShowChangeMarker(count: number | undefined) { + return count === undefined || count > 0; +} + +function addIncomingOutgoingChangeRows( + rows: GraphRow[], + commits: readonly GitGraphCommit[], + options: GitGraphOptions, +) { + const currentSha = findCommitShaForRef(commits, options.currentRef ?? ""); + const remoteSha = findCommitShaForRef(commits, options.remoteRef ?? ""); + if (!currentSha || !remoteSha || currentSha === remoteSha) return; + + const mergeBase = + options.mergeBase?.trim() || inferCommonAncestorSha(commits, currentSha, remoteSha); + if (!mergeBase) return; + + if ( + shouldShowChangeMarker(options.behind) && + remoteSha !== mergeBase && + rows.some((row) => row.sha === mergeBase) + ) { + const beforeIndex = findLastGraphRowIndex(rows, (row) => + row.outputLanes.some((lane) => lane.id === mergeBase), + ); + const afterIndex = rows.findIndex((row) => row.kind === "commit" && row.sha === mergeBase); + + if (beforeIndex !== -1 && afterIndex !== -1) { + const incomingChangeMerged = + rows[beforeIndex].parents.length === 2 && rows[beforeIndex].parents.includes(mergeBase); + + if (!incomingChangeMerged) { + rows[beforeIndex] = { + ...rows[beforeIndex], + inputLanes: rows[beforeIndex].inputLanes.map((lane) => + lane.id === mergeBase && lane.color === GRAPH_REF_COLORS.remote + ? { ...lane, id: GIT_GRAPH_INCOMING_CHANGES_ID } + : cloneLane(lane), + ), + outputLanes: rows[beforeIndex].outputLanes.map((lane) => + lane.id === mergeBase && lane.color === GRAPH_REF_COLORS.remote + ? { ...lane, id: GIT_GRAPH_INCOMING_CHANGES_ID } + : cloneLane(lane), + ), + }; + + rows.splice( + afterIndex, + 0, + createSyntheticGraphRow({ + kind: "incoming-changes", + sha: GIT_GRAPH_INCOMING_CHANGES_ID, + parents: [mergeBase], + inputLanes: rows[beforeIndex].outputLanes.map(cloneLane), + outputLanes: rows[afterIndex].inputLanes.map(cloneLane), + color: GRAPH_REF_COLORS.remote, + }), + ); + } + } + } + + if (shouldShowChangeMarker(options.ahead) && currentSha !== mergeBase) { + const currentIndex = rows.findIndex((row) => row.kind === "commit" && row.sha === currentSha); + if (currentIndex !== -1) { + const inputLanes = rows[currentIndex].inputLanes.map(cloneLane); + rows.splice( + currentIndex, + 0, + createSyntheticGraphRow({ + kind: "outgoing-changes", + sha: GIT_GRAPH_OUTGOING_CHANGES_ID, + parents: [currentSha], + inputLanes, + outputLanes: [ + ...inputLanes.map(cloneLane), + { id: currentSha, color: GRAPH_REF_COLORS.local }, + ], + color: GRAPH_REF_COLORS.local, + }), + ); + rows[currentIndex + 1] = { + ...rows[currentIndex + 1], + inputLanes: [ + ...rows[currentIndex + 1].inputLanes.map(cloneLane), + { id: currentSha, color: GRAPH_REF_COLORS.local }, + ], + }; + } + } +} + +function graphColumnCount(row: GraphRow) { + return Math.max(row.inputLanes.length, row.outputLanes.length, row.commitCol + 1, 1); +} + +function calculateMaxCols(rows: readonly GraphRow[]) { + return rows.reduce((maxCols, row) => Math.max(maxCols, graphColumnCount(row)), 0); +} + export function computeGitGraph( commits: readonly GitGraphCommit[], options: GitGraphOptions = {}, @@ -118,6 +309,7 @@ export function computeGitGraph( const rows: GraphRow[] = []; const commitBySha = new Map(commits.map((commit) => [commit.sha, commit])); const refColorMap = createRefColorMap(options); + const currentHeadSha = findCommitShaForRef(commits, options.currentRef ?? ""); let nextColor = -1; let previousOutputLanes: GraphLane[] = []; let maxCols = 1; @@ -166,17 +358,23 @@ export function computeGitGraph( maxCols = Math.max(maxCols, inputLanes.length, outputLanes.length, commitCol + 1); rows.push({ + kind: "commit", sha: commit.sha, parents, commitCol, commitColor, inputLanes, outputLanes, - isHead: index === 0, + isHead: currentHeadSha ? commit.sha === currentHeadSha : index === 0, isMerge: parents.length > 1, }); previousOutputLanes = outputLanes; } + if (options.showRemoteChangeMarkers) { + addIncomingOutgoingChangeRows(rows, commits, options); + maxCols = Math.max(maxCols, calculateMaxCols(rows)); + } + return { rows, maxCols }; } diff --git a/crates/agent-gui/src/lib/git/types.ts b/crates/agent-gui/src/lib/git/types.ts index ffab07e7c..200e16fb7 100644 --- a/crates/agent-gui/src/lib/git/types.ts +++ b/crates/agent-gui/src/lib/git/types.ts @@ -81,6 +81,10 @@ export type GitCommitSummary = { export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; + historyBaseRef: string; + historyAhead: number; + historyBehind: number; + mergeBase: string; }; export type GitCommitDetails = { @@ -289,6 +293,10 @@ export function normalizeGitLogResponse(input: unknown, workdir = ""): GitLogRes return { state: normalizeGitRepositoryState(source.state, workdir), commits: Array.isArray(source.commits) ? source.commits.map(normalizeGitCommitSummary) : [], + historyBaseRef: asString(source.historyBaseRef ?? source.history_base_ref), + historyAhead: asNumber(source.historyAhead ?? source.history_ahead), + historyBehind: asNumber(source.historyBehind ?? source.history_behind), + mergeBase: asString(source.mergeBase ?? source.merge_base), }; } diff --git a/crates/agent-gui/test/tools/git-graph.test.mjs b/crates/agent-gui/test/tools/git-graph.test.mjs index bacd8d37c..c5a1faf6d 100644 --- a/crates/agent-gui/test/tools/git-graph.test.mjs +++ b/crates/agent-gui/test/tools/git-graph.test.mjs @@ -183,7 +183,7 @@ for (const [surface, graph] of Object.entries(graphModules)) { { id: "merge", color: 0 }, { id: "side", color: graph.GRAPH_REF_COLORS.remote }, ], - isHead: true, + isHead: false, isMerge: true, }, { @@ -199,7 +199,7 @@ for (const [surface, graph] of Object.entries(graphModules)) { { id: "base", color: graph.GRAPH_REF_COLORS.local }, { id: "side", color: graph.GRAPH_REF_COLORS.remote }, ], - isHead: false, + isHead: true, isMerge: false, }, { @@ -234,6 +234,72 @@ for (const [surface, graph] of Object.entries(graphModules)) { ]); }); + test(`${surface} git graph inserts VS Code incoming and outgoing change rows`, () => { + const result = graph.computeGitGraph( + [ + { sha: "a", parents: ["b"], refs: ["origin/main"] }, + { sha: "b", parents: ["e"] }, + { sha: "c", parents: ["d"], refs: ["main"] }, + { sha: "d", parents: ["e"] }, + { sha: "e", parents: ["f"] }, + { sha: "f", parents: ["g"] }, + ], + { + currentRef: "main", + remoteRef: "origin/main", + showRemoteChangeMarkers: true, + ahead: 2, + behind: 2, + }, + ); + + assert.equal(result.maxCols, 2); + assert.deepEqual( + result.rows.map((row) => row.kind), + [ + "commit", + "commit", + "outgoing-changes", + "commit", + "commit", + "incoming-changes", + "commit", + "commit", + ], + ); + + const outgoing = result.rows[2]; + assert.equal(outgoing.sha, graph.GIT_GRAPH_OUTGOING_CHANGES_ID); + assert.deepEqual(outgoing.parents, ["c"]); + assert.equal(outgoing.commitCol, 1); + assert.deepEqual(outgoing.inputLanes, [{ id: "e", color: graph.GRAPH_REF_COLORS.remote }]); + assert.deepEqual(outgoing.outputLanes, [ + { id: "e", color: graph.GRAPH_REF_COLORS.remote }, + { id: "c", color: graph.GRAPH_REF_COLORS.local }, + ]); + + const head = result.rows[3]; + assert.equal(head.sha, "c"); + assert.equal(head.isHead, true); + assert.deepEqual(head.inputLanes, [ + { id: "e", color: graph.GRAPH_REF_COLORS.remote }, + { id: "c", color: graph.GRAPH_REF_COLORS.local }, + ]); + + const incoming = result.rows[5]; + assert.equal(incoming.sha, graph.GIT_GRAPH_INCOMING_CHANGES_ID); + assert.deepEqual(incoming.parents, ["e"]); + assert.equal(incoming.commitCol, 0); + assert.deepEqual(incoming.inputLanes, [ + { id: graph.GIT_GRAPH_INCOMING_CHANGES_ID, color: graph.GRAPH_REF_COLORS.remote }, + { id: "e", color: graph.GRAPH_REF_COLORS.local }, + ]); + assert.deepEqual(incoming.outputLanes, [ + { id: "e", color: graph.GRAPH_REF_COLORS.remote }, + { id: "e", color: graph.GRAPH_REF_COLORS.local }, + ]); + }); + test(`${surface} git graph keeps an already-active merge parent as a new VS Code lane`, () => { const result = graph.computeGitGraph([ { sha: "tip", parents: ["merge", "side"] }, From caeb342b273f5fbe9922ba3937ca25c3dac09271 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 12:25:17 +0800 Subject: [PATCH 08/10] fix(project-tools): preserve editor state across file tree tabs --- crates/agent-gateway/web/src/App.tsx | 40 ++++++++-- .../WorkspaceCodeEditorOverlay.tsx | 78 +++++++++++++++---- .../WorkspaceCodeEditorOverlay.tsx | 78 +++++++++++++++---- crates/agent-gui/src/pages/ChatPage.tsx | 40 ++++++++-- 4 files changed, 194 insertions(+), 42 deletions(-) diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index b099794fb..f5c179b7e 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -817,7 +817,12 @@ export default function App() { const [isFileDropActive, setIsFileDropActive] = useState(false); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const projectToolsFileTreeOpenCount = + settings.customSettings.projectToolsFileTree.openProjectPathKeys.length; + const previousProjectToolsFileTreeOpenCountRef = useRef(projectToolsFileTreeOpenCount); + const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); + const [workspaceEditorCleanupPending, setWorkspaceEditorCleanupPending] = useState(false); const [workspaceEditorOpenRequest, setWorkspaceEditorOpenRequest] = useState(null); const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0); @@ -5294,6 +5299,8 @@ export default function App() { (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; workspaceEditorRequestIdRef.current += 1; + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorMounted(true); setWorkspaceEditorOpen(true); setWorkspaceEditorOpenRequest({ id: workspaceEditorRequestIdRef.current, @@ -5307,6 +5314,23 @@ export default function App() { const requestWorkspaceEditorClose = useCallback(() => { setWorkspaceEditorCloseRequestId((current) => current + 1); }, []); + useEffect(() => { + const previousOpenCount = previousProjectToolsFileTreeOpenCountRef.current; + previousProjectToolsFileTreeOpenCountRef.current = projectToolsFileTreeOpenCount; + if (projectToolsFileTreeOpenCount > 0 && workspaceEditorCleanupPending) { + setWorkspaceEditorCleanupPending(false); + } + if (previousOpenCount > 0 && projectToolsFileTreeOpenCount === 0 && workspaceEditorMounted) { + setWorkspaceEditorCleanupPending(true); + setWorkspaceEditorOpen(true); + requestWorkspaceEditorClose(); + } + }, [ + projectToolsFileTreeOpenCount, + requestWorkspaceEditorClose, + workspaceEditorCleanupPending, + workspaceEditorMounted, + ]); const projectTerminalSessions = useMemo( () => terminalProjectPathKey @@ -6292,7 +6316,7 @@ export default function App() {
)} - {workspaceEditorOpen ? ( + {workspaceEditorMounted ? ( @@ -6303,8 +6327,17 @@ export default function App() { setWorkspaceEditorOpen(false)} + onHide={() => setWorkspaceEditorOpen(false)} + onClose={() => { + setWorkspaceEditorOpen(false); + setWorkspaceEditorMounted(false); + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorOpenRequest(null); + setWorkspaceEditorCloseRequestId(0); + }} /> ) : null} @@ -6368,9 +6401,6 @@ export default function App() { setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open), ); - if (!open) { - requestWorkspaceEditorClose(); - } }} onFileTreeStateChange={(patch) => setSettings((prev) => diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index c6be694bc..95e6d6cf0 100644 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -114,7 +114,10 @@ const EDITOR_CONTEXT_MENU_HEIGHT = 300; type WorkspaceCodeEditorOverlayProps = { openRequest: WorkspaceCodeEditorOpenRequest | null; closeRequestId?: number; + isOpen: boolean; + finalCloseRequested?: boolean; theme: "light" | "dark"; + onHide: () => void; onClose: () => void; }; @@ -268,7 +271,15 @@ function getEditorContextMenuShortcuts() { } export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProps) { - const { openRequest, closeRequestId, theme, onClose } = props; + const { + openRequest, + closeRequestId, + isOpen, + finalCloseRequested = false, + theme, + onHide, + onClose, + } = props; const { t } = useLocale(); const contextMenuShortcuts = useMemo(() => getEditorContextMenuShortcuts(), []); const overlayRef = useRef(null); @@ -321,9 +332,21 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp setIsVisible(true); }, []); - const finishClose = useCallback(() => { + const finishHide = useCallback(() => { if (closeAnimationTimeoutRef.current !== null) return; setIsVisible(false); + closeAnimationTimeoutRef.current = window.setTimeout(() => { + closeAnimationTimeoutRef.current = null; + onHide(); + }, EDITOR_OVERLAY_ANIMATION_MS); + }, [onHide]); + + const finishClose = useCallback(() => { + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + closeAnimationTimeoutRef.current = null; + } + setIsVisible(false); closeAnimationTimeoutRef.current = window.setTimeout(() => { closeAnimationTimeoutRef.current = null; onClose(); @@ -500,13 +523,10 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (currentActive !== tabKey) return currentActive; return next[Math.min(index, next.length - 1)]?.key ?? ""; }); - if (next.length === 0) { - window.requestAnimationFrame(finishClose); - } return next; }); }, - [disposeModel, finishClose], + [disposeModel], ); const requestCloseTab = useCallback( @@ -543,16 +563,15 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp finishClose(); }, [finishClose, hasDirtyTabs]); - useEffect(() => { - if (closeRequestId == null) return; - if (closeRequestIdRef.current == null) { - closeRequestIdRef.current = closeRequestId; + const hideOverlay = useCallback(() => { + if (finalCloseRequested) { + requestCloseOverlay(); return; } - if (closeRequestIdRef.current === closeRequestId) return; - closeRequestIdRef.current = closeRequestId; - requestCloseOverlay(); - }, [closeRequestId, requestCloseOverlay]); + setPendingDialog(null); + setContextMenu(null); + finishHide(); + }, [finalCloseRequested, finishHide, requestCloseOverlay]); const discardDialogTarget = useCallback(() => { const dialog = pendingDialog; @@ -634,9 +653,35 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (!openRequest || openRequestIdRef.current === openRequest.id) return; openRequestIdRef.current = openRequest.id; cancelPendingClose(); + setIsVisible(true); void readTab(openRequest); }, [cancelPendingClose, openRequest, readTab]); + useEffect(() => { + cancelPendingClose(); + setIsVisible(isOpen); + }, [cancelPendingClose, isOpen]); + + useEffect(() => { + if (closeRequestId == null) return; + if (closeRequestIdRef.current == null) { + closeRequestIdRef.current = closeRequestId; + return; + } + if (closeRequestIdRef.current === closeRequestId) return; + closeRequestIdRef.current = closeRequestId; + requestCloseOverlay(); + }, [closeRequestId, requestCloseOverlay]); + + useEffect(() => { + if (finalCloseRequested) return; + cancelPendingClose(); + if (isOpen) { + setIsVisible(true); + } + setPendingDialog((current) => (current?.kind === "closeOverlay" ? null : current)); + }, [cancelPendingClose, finalCloseRequested, isOpen]); + useEffect(() => { activeKeyRef.current = activeTab?.key ?? ""; }, [activeTab?.key]); @@ -723,6 +768,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (event.key === "Escape") { setContextMenu(null); } + if (!isOpen) return; if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return; const currentKey = activeKeyRef.current; if (!currentKey) return; @@ -731,7 +777,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [saveTab]); + }, [isOpen, saveTab]); useEffect(() => { if (!contextMenu) return; @@ -813,7 +859,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp > - +
diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index bceb7180e..0176b8ce5 100644 --- a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -115,7 +115,10 @@ const EDITOR_CONTEXT_MENU_HEIGHT = 300; type WorkspaceCodeEditorOverlayProps = { openRequest: WorkspaceCodeEditorOpenRequest | null; closeRequestId?: number; + isOpen: boolean; + finalCloseRequested?: boolean; theme: "light" | "dark"; + onHide: () => void; onClose: () => void; }; @@ -269,7 +272,15 @@ function getEditorContextMenuShortcuts() { } export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProps) { - const { openRequest, closeRequestId, theme, onClose } = props; + const { + openRequest, + closeRequestId, + isOpen, + finalCloseRequested = false, + theme, + onHide, + onClose, + } = props; const { t } = useLocale(); const contextMenuShortcuts = useMemo(() => getEditorContextMenuShortcuts(), []); const overlayRef = useRef(null); @@ -322,9 +333,21 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp setIsVisible(true); }, []); - const finishClose = useCallback(() => { + const finishHide = useCallback(() => { if (closeAnimationTimeoutRef.current !== null) return; setIsVisible(false); + closeAnimationTimeoutRef.current = window.setTimeout(() => { + closeAnimationTimeoutRef.current = null; + onHide(); + }, EDITOR_OVERLAY_ANIMATION_MS); + }, [onHide]); + + const finishClose = useCallback(() => { + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + closeAnimationTimeoutRef.current = null; + } + setIsVisible(false); closeAnimationTimeoutRef.current = window.setTimeout(() => { closeAnimationTimeoutRef.current = null; onClose(); @@ -501,13 +524,10 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (currentActive !== tabKey) return currentActive; return next[Math.min(index, next.length - 1)]?.key ?? ""; }); - if (next.length === 0) { - window.requestAnimationFrame(finishClose); - } return next; }); }, - [disposeModel, finishClose], + [disposeModel], ); const requestCloseTab = useCallback( @@ -544,16 +564,15 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp finishClose(); }, [finishClose, hasDirtyTabs]); - useEffect(() => { - if (closeRequestId == null) return; - if (closeRequestIdRef.current == null) { - closeRequestIdRef.current = closeRequestId; + const hideOverlay = useCallback(() => { + if (finalCloseRequested) { + requestCloseOverlay(); return; } - if (closeRequestIdRef.current === closeRequestId) return; - closeRequestIdRef.current = closeRequestId; - requestCloseOverlay(); - }, [closeRequestId, requestCloseOverlay]); + setPendingDialog(null); + setContextMenu(null); + finishHide(); + }, [finalCloseRequested, finishHide, requestCloseOverlay]); const discardDialogTarget = useCallback(() => { const dialog = pendingDialog; @@ -635,9 +654,35 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (!openRequest || openRequestIdRef.current === openRequest.id) return; openRequestIdRef.current = openRequest.id; cancelPendingClose(); + setIsVisible(true); void readTab(openRequest); }, [cancelPendingClose, openRequest, readTab]); + useEffect(() => { + cancelPendingClose(); + setIsVisible(isOpen); + }, [cancelPendingClose, isOpen]); + + useEffect(() => { + if (closeRequestId == null) return; + if (closeRequestIdRef.current == null) { + closeRequestIdRef.current = closeRequestId; + return; + } + if (closeRequestIdRef.current === closeRequestId) return; + closeRequestIdRef.current = closeRequestId; + requestCloseOverlay(); + }, [closeRequestId, requestCloseOverlay]); + + useEffect(() => { + if (finalCloseRequested) return; + cancelPendingClose(); + if (isOpen) { + setIsVisible(true); + } + setPendingDialog((current) => (current?.kind === "closeOverlay" ? null : current)); + }, [cancelPendingClose, finalCloseRequested, isOpen]); + useEffect(() => { activeKeyRef.current = activeTab?.key ?? ""; }, [activeTab?.key]); @@ -724,6 +769,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp if (event.key === "Escape") { setContextMenu(null); } + if (!isOpen) return; if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return; const currentKey = activeKeyRef.current; if (!currentKey) return; @@ -732,7 +778,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [saveTab]); + }, [isOpen, saveTab]); useEffect(() => { if (!contextMenu) return; @@ -815,7 +861,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp > - +

diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 35b4238e2..021e2eb7e 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -690,7 +690,12 @@ export function ChatPage(props: ChatPageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const projectToolsFileTreeOpenCount = + settings.customSettings.projectToolsFileTree.openProjectPathKeys.length; + const previousProjectToolsFileTreeOpenCountRef = useRef(projectToolsFileTreeOpenCount); + const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); + const [workspaceEditorCleanupPending, setWorkspaceEditorCleanupPending] = useState(false); const [workspaceEditorOpenRequest, setWorkspaceEditorOpenRequest] = useState(null); const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0); @@ -1465,6 +1470,8 @@ export function ChatPage(props: ChatPageProps) { (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; workspaceEditorRequestIdRef.current += 1; + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorMounted(true); setWorkspaceEditorOpen(true); setWorkspaceEditorOpenRequest({ id: workspaceEditorRequestIdRef.current, @@ -1478,6 +1485,23 @@ export function ChatPage(props: ChatPageProps) { const requestWorkspaceEditorClose = useCallback(() => { setWorkspaceEditorCloseRequestId((current) => current + 1); }, []); + useEffect(() => { + const previousOpenCount = previousProjectToolsFileTreeOpenCountRef.current; + previousProjectToolsFileTreeOpenCountRef.current = projectToolsFileTreeOpenCount; + if (projectToolsFileTreeOpenCount > 0 && workspaceEditorCleanupPending) { + setWorkspaceEditorCleanupPending(false); + } + if (previousOpenCount > 0 && projectToolsFileTreeOpenCount === 0 && workspaceEditorMounted) { + setWorkspaceEditorCleanupPending(true); + setWorkspaceEditorOpen(true); + requestWorkspaceEditorClose(); + } + }, [ + projectToolsFileTreeOpenCount, + requestWorkspaceEditorClose, + workspaceEditorCleanupPending, + workspaceEditorMounted, + ]); useEffect(() => { if (!terminalProjectPathKey) { setProjectTerminalSessions([]); @@ -4451,7 +4475,7 @@ export function ChatPage(props: ChatPageProps) { )}
- {workspaceEditorOpen ? ( + {workspaceEditorMounted ? ( @@ -4465,8 +4489,17 @@ export function ChatPage(props: ChatPageProps) { setWorkspaceEditorOpen(false)} + onHide={() => setWorkspaceEditorOpen(false)} + onClose={() => { + setWorkspaceEditorOpen(false); + setWorkspaceEditorMounted(false); + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorOpenRequest(null); + setWorkspaceEditorCloseRequestId(0); + }} /> ) : null} @@ -4518,9 +4551,6 @@ export function ChatPage(props: ChatPageProps) { } onFileTreeOpenChange={(open) => { setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open)); - if (!open) { - requestWorkspaceEditorClose(); - } }} onFileTreeStateChange={(patch) => setSettings((prev) => From e0cc728543f08ca9ed1b1c25912781e96b50019b Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 13:24:18 +0800 Subject: [PATCH 09/10] fix(git-review): align upstream merge graph with VS Code --- .../project-tools/GitReviewPanel.tsx | 15 +- crates/agent-gateway/web/src/lib/git/types.ts | 9 +- .../agent-gui/src-tauri/src/commands/git.rs | 190 ++++++++++++++++-- .../project-tools/GitReviewPanel.tsx | 15 +- crates/agent-gui/src/lib/git/types.ts | 9 +- .../agent-gui/test/tools/git-graph.test.mjs | 48 +++++ 6 files changed, 255 insertions(+), 31 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index 652be29a7..bbc1114b1 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -477,7 +477,7 @@ type GitReviewStackedPane = "list" | "detail"; type GitHistoryMarkerKind = Extract; type GitHistoryGraphState = Pick< GitLogResponse, - "historyBaseRef" | "historyAhead" | "historyBehind" | "mergeBase" + "historyBaseRef" | "historyRemoteRef" | "historyAhead" | "historyBehind" | "mergeBase" >; type GitHistoryRow = | { @@ -1780,13 +1780,14 @@ function commitHistoryTitle(commit: GitCommitSummary) { function gitHistoryMarkerRef( kind: GitHistoryMarkerKind, state: Pick, - historyBaseRef: string, + historyRemoteRef: string, ) { - return kind === "outgoing-changes" ? state.head : historyBaseRef; + return kind === "outgoing-changes" ? state.head : historyRemoteRef; } const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { historyBaseRef: "", + historyRemoteRef: "", historyAhead: 0, historyBehind: 0, mergeBase: "", @@ -1795,6 +1796,7 @@ const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { function gitHistoryGraphStateFromResponse(response: GitLogResponse): GitHistoryGraphState { return { historyBaseRef: response.historyBaseRef, + historyRemoteRef: response.historyRemoteRef, historyAhead: response.historyAhead, historyBehind: response.historyBehind, mergeBase: response.mergeBase, @@ -2441,7 +2443,7 @@ function gitHistorySignature( ].join("\x1e"), ) .join("\x1f"); - return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; + return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyRemoteRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; } function gitDiffSignature(diff: GitDiffResponse) { @@ -3408,7 +3410,8 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel () => computeGitGraph(historyCommits, { currentRef: state.head, - remoteRef: historyGraphState.historyBaseRef, + remoteRef: historyGraphState.historyRemoteRef, + baseRef: historyGraphState.historyBaseRef, remoteName: state.remoteName, showRemoteChangeMarkers: true, ahead: historyGraphState.historyAhead, @@ -4548,7 +4551,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const refLabel = gitHistoryMarkerRef( row.kind, state, - historyGraphState.historyBaseRef, + historyGraphState.historyRemoteRef, ); const title = refLabel ? `${label} ${refLabel}` : label; return ( diff --git a/crates/agent-gateway/web/src/lib/git/types.ts b/crates/agent-gateway/web/src/lib/git/types.ts index 6d33fd9d3..c53293fef 100644 --- a/crates/agent-gateway/web/src/lib/git/types.ts +++ b/crates/agent-gateway/web/src/lib/git/types.ts @@ -82,6 +82,7 @@ export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; historyBaseRef: string; + historyRemoteRef: string; historyAhead: number; historyBehind: number; mergeBase: string; @@ -286,10 +287,16 @@ export function normalizeGitCommitSummary(input: unknown): GitCommitSummary { export function normalizeGitLogResponse(input: unknown, workdir = ""): GitLogResponse { const source = asObject(input); + const rawHistoryBaseRef = asString(source.historyBaseRef ?? source.history_base_ref); + const hasHistoryRemoteRef = + Object.prototype.hasOwnProperty.call(source, "historyRemoteRef") || + Object.prototype.hasOwnProperty.call(source, "history_remote_ref"); + const rawHistoryRemoteRef = asString(source.historyRemoteRef ?? source.history_remote_ref); return { state: normalizeGitRepositoryState(source.state, workdir), commits: Array.isArray(source.commits) ? source.commits.map(normalizeGitCommitSummary) : [], - historyBaseRef: asString(source.historyBaseRef ?? source.history_base_ref), + historyBaseRef: hasHistoryRemoteRef ? rawHistoryBaseRef : "", + historyRemoteRef: rawHistoryRemoteRef || rawHistoryBaseRef, historyAhead: asNumber(source.historyAhead ?? source.history_ahead), historyBehind: asNumber(source.historyBehind ?? source.history_behind), mergeBase: asString(source.mergeBase ?? source.merge_base), diff --git a/crates/agent-gui/src-tauri/src/commands/git.rs b/crates/agent-gui/src-tauri/src/commands/git.rs index 24da88c03..742b95964 100644 --- a/crates/agent-gui/src-tauri/src/commands/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/git.rs @@ -124,6 +124,7 @@ pub struct GitLogResponse { pub state: GitRepositoryState, pub commits: Vec, pub history_base_ref: String, + pub history_remote_ref: String, pub history_ahead: i32, pub history_behind: i32, pub merge_base: String, @@ -1383,12 +1384,97 @@ fn local_only_commit_shas(repo_root: &str, cloud_ref: &str) -> HashSet { .unwrap_or_default() } -fn resolve_history_base_ref(state: &GitRepositoryState) -> String { - let cloud_ref = resolve_cloud_tracking_ref(state); - if !cloud_ref.trim().is_empty() { - return cloud_ref; +fn normalized_ref_name(reference: &str) -> String { + let mut value = reference.trim(); + for prefix in ["refs/heads/", "refs/remotes/", "refs/tags/"] { + if let Some(stripped) = value.strip_prefix(prefix) { + value = stripped; + break; + } + } + value.to_string() +} + +fn comparable_branch_name(reference: &str) -> String { + let normalized = normalized_ref_name(reference); + if let Some(stripped) = normalized.strip_prefix("origin/") { + return stripped.to_string(); + } + normalized +} + +fn refs_share_branch_name(a: &str, b: &str) -> bool { + let a = comparable_branch_name(a); + let b = comparable_branch_name(b); + if a.is_empty() || b.is_empty() { + return false; + } + a == b +} + +fn is_default_history_branch(reference: &str) -> bool { + matches!( + comparable_branch_name(reference).as_str(), + "main" | "master" | "develop" + ) +} + +fn resolve_history_base_ref(state: &GitRepositoryState, history_remote_ref: &str) -> String { + let current_head = state.head.trim(); + let current_local_ref = if current_head.is_empty() || current_head == "(detached)" { + String::new() + } else { + current_head.to_string() + }; + if is_default_history_branch(¤t_local_ref) { + return String::new(); + } + for candidate in [ + "origin/main", + "origin/master", + "origin/develop", + "main", + "master", + "develop", + ] { + if refs_share_branch_name(candidate, history_remote_ref) + || refs_share_branch_name(candidate, ¤t_local_ref) + { + continue; + } + if ref_exists(&state.repo_root, candidate) { + return candidate.to_string(); + } } - resolve_review_base(state) + String::new() +} + +fn push_unique_ref(refs: &mut Vec, reference: String) { + let reference = reference.trim(); + if reference.is_empty() || refs.iter().any(|existing| existing == reference) { + return; + } + refs.push(reference.to_string()); +} + +fn resolve_history_log_refs( + state: &GitRepositoryState, + history_remote_ref: &str, + history_base_ref: &str, +) -> Vec { + let mut refs = Vec::new(); + push_unique_ref(&mut refs, "HEAD".to_string()); + + let current_ref = if state.head.trim().is_empty() || state.head == "(detached)" { + String::new() + } else { + format!("refs/heads/{}", state.head) + }; + push_unique_ref(&mut refs, current_ref); + push_unique_ref(&mut refs, history_remote_ref.to_string()); + push_unique_ref(&mut refs, history_base_ref.to_string()); + + refs } fn resolve_history_merge_base(repo_root: &str, history_base_ref: &str) -> String { @@ -1476,6 +1562,7 @@ pub(crate) fn git_log_sync( state, commits: Vec::new(), history_base_ref: String::new(), + history_remote_ref: String::new(), history_ahead: 0, history_behind: 0, merge_base: String::new(), @@ -1486,6 +1573,7 @@ pub(crate) fn git_log_sync( state, commits: Vec::new(), history_base_ref: String::new(), + history_remote_ref: String::new(), history_ahead: 0, history_behind: 0, merge_base: String::new(), @@ -1510,14 +1598,15 @@ pub(crate) fn git_log_sync( if skip > 0 { args.push(format!("--skip={skip}")); } - args.extend([ - "--pretty=format:%x1e%H%x1f%h%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string(), - "HEAD".to_string(), - ]); - let review_ref = resolve_history_base_ref(&state); - if !review_ref.trim().is_empty() && review_ref != "HEAD" { - args.push(review_ref.clone()); - } + let cloud_ref = resolve_cloud_tracking_ref(&state); + let review_ref = if cloud_ref.trim().is_empty() { + resolve_review_base(&state) + } else { + cloud_ref.clone() + }; + let history_base_ref = resolve_history_base_ref(&state, &review_ref); + args.push("--pretty=format:%x1e%H%x1f%h%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string()); + args.extend(resolve_history_log_refs(&state, &review_ref, &history_base_ref)); let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let output = git_success(&state.repo_root, &arg_refs)?; let mut commits = parse_git_log(&output.stdout); @@ -1534,7 +1623,6 @@ pub(crate) fn git_log_sync( } } } - let cloud_ref = resolve_cloud_tracking_ref(&state); let local_only_shas = local_only_commit_shas(&state.repo_root, &cloud_ref); if cloud_ref.trim().is_empty() { for commit in &mut commits { @@ -1550,7 +1638,8 @@ pub(crate) fn git_log_sync( Ok(GitLogResponse { state, commits, - history_base_ref: review_ref, + history_base_ref, + history_remote_ref: review_ref, history_ahead, history_behind, merge_base, @@ -3104,7 +3193,8 @@ mod tests { "test branch should not have upstream: {}", history.state.upstream ); - assert_eq!(history.history_base_ref, initial.head); + assert_eq!(history.history_base_ref, ""); + assert_eq!(history.history_remote_ref, initial.head); assert_eq!(history.history_ahead, 1); assert_eq!(history.history_behind, 1); assert_eq!(history.merge_base, initial_sha); @@ -3375,6 +3465,7 @@ mod tests { git_set_remote_sync(workdir.clone(), remote.path().to_string_lossy().to_string()) .expect("set origin remote"); assert!(saved.ok, "set remote failed: {}", saved.message); + let initial = git_status_sync(workdir.clone()).expect("initial status"); run_temp_git(repo.path(), &["checkout", "-b", "feature/local-only"]); run_temp_git(repo.path(), &["config", "push.autoSetupRemote", "false"]); run_temp_git(repo.path(), &["push", "origin", "feature/local-only"]); @@ -3396,7 +3487,8 @@ mod tests { .trim() .to_string(); let history = git_log_sync(workdir, Some(10), None).expect("git log"); - assert_eq!(history.history_base_ref, "origin/feature/local-only"); + assert_eq!(history.history_base_ref, initial.head); + assert_eq!(history.history_remote_ref, "origin/feature/local-only"); assert_eq!(history.history_ahead, 1); assert_eq!(history.history_behind, 0); assert_eq!(history.merge_base, remote_feature_sha); @@ -3420,6 +3512,70 @@ mod tests { ); } + #[test] + fn git_log_includes_remote_base_merge_outside_current_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let initial = git_status_sync(workdir.clone()).expect("initial status"); + + run_temp_git(repo.path(), &["checkout", "-b", "features"]); + fs::write(repo.path().join("feature-1.txt"), "feature 1\n").expect("write feature 1"); + run_temp_git(repo.path(), &["add", "feature-1.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "feature one"]); + + run_temp_git(repo.path(), &["branch", "origin/features", "HEAD"]); + run_temp_git( + repo.path(), + &[ + "branch", + "--set-upstream-to", + "origin/features", + "features", + ], + ); + + fs::write(repo.path().join("feature-2.txt"), "feature 2\n").expect("write feature 2"); + run_temp_git(repo.path(), &["add", "feature-2.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "feature two"]); + + run_temp_git(repo.path(), &["checkout", initial.head.as_str()]); + run_temp_git( + repo.path(), + &[ + "merge", + "--no-ff", + "-m", + "Merge pull request #52 from Stack-Cairn/features", + "features", + ], + ); + let main_merge_sha = git_success(&workdir, &["rev-parse", "HEAD"]) + .expect("read main merge") + .stdout + .trim() + .to_string(); + run_temp_git(repo.path(), &["branch", "origin/main", "HEAD"]); + run_temp_git(repo.path(), &["checkout", "features"]); + + let history = git_log_sync(workdir, Some(10), None).expect("git log"); + assert_eq!(history.history_base_ref, "origin/main"); + assert_eq!(history.history_remote_ref, "origin/features"); + assert!( + history.commits.iter().any(|commit| { + commit.sha == main_merge_sha + && commit.subject == "Merge pull request #52 from Stack-Cairn/features" + }), + "history should include origin/main merge outside current branch: {:?}", + history + .commits + .iter() + .map(|commit| commit.subject.as_str()) + .collect::>() + ); + } + #[test] fn git_fetch_guides_without_remote() { let Some(repo) = init_temp_repo() else { diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx index fde0caa21..f5bf84b2f 100644 --- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx @@ -525,7 +525,7 @@ type GitReviewStackedPane = "list" | "detail"; type GitHistoryMarkerKind = Extract; type GitHistoryGraphState = Pick< GitLogResponse, - "historyBaseRef" | "historyAhead" | "historyBehind" | "mergeBase" + "historyBaseRef" | "historyRemoteRef" | "historyAhead" | "historyBehind" | "mergeBase" >; type GitHistoryRow = | { @@ -2254,7 +2254,7 @@ function gitHistorySignature( ].join("\x1e"), ) .join("\x1f"); - return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; + return `${gitRepositoryStateSignature(state)}\x1d${historyGraphState.historyBaseRef}\x1e${historyGraphState.historyRemoteRef}\x1e${historyGraphState.historyAhead}\x1e${historyGraphState.historyBehind}\x1e${historyGraphState.mergeBase}\x1c${commitsSignature}`; } function gitDiffSignature(diff: GitDiffResponse) { @@ -2380,13 +2380,14 @@ function commitHistoryTitle(commit: GitCommitSummary) { function gitHistoryMarkerRef( kind: GitHistoryMarkerKind, state: Pick, - historyBaseRef: string, + historyRemoteRef: string, ) { - return kind === "outgoing-changes" ? state.head : historyBaseRef; + return kind === "outgoing-changes" ? state.head : historyRemoteRef; } const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { historyBaseRef: "", + historyRemoteRef: "", historyAhead: 0, historyBehind: 0, mergeBase: "", @@ -2395,6 +2396,7 @@ const EMPTY_GIT_HISTORY_GRAPH_STATE: GitHistoryGraphState = { function gitHistoryGraphStateFromResponse(response: GitLogResponse): GitHistoryGraphState { return { historyBaseRef: response.historyBaseRef, + historyRemoteRef: response.historyRemoteRef, historyAhead: response.historyAhead, historyBehind: response.historyBehind, mergeBase: response.mergeBase, @@ -3743,7 +3745,8 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel () => computeGitGraph(historyCommits, { currentRef: state.head, - remoteRef: historyGraphState.historyBaseRef, + remoteRef: historyGraphState.historyRemoteRef, + baseRef: historyGraphState.historyBaseRef, remoteName: state.remoteName, showRemoteChangeMarkers: true, ahead: historyGraphState.historyAhead, @@ -4929,7 +4932,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const refLabel = gitHistoryMarkerRef( row.kind, state, - historyGraphState.historyBaseRef, + historyGraphState.historyRemoteRef, ); const title = refLabel ? `${label} ${refLabel}` : label; return ( diff --git a/crates/agent-gui/src/lib/git/types.ts b/crates/agent-gui/src/lib/git/types.ts index 200e16fb7..feaa3b920 100644 --- a/crates/agent-gui/src/lib/git/types.ts +++ b/crates/agent-gui/src/lib/git/types.ts @@ -82,6 +82,7 @@ export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; historyBaseRef: string; + historyRemoteRef: string; historyAhead: number; historyBehind: number; mergeBase: string; @@ -290,10 +291,16 @@ export function normalizeGitCommitSummary(input: unknown): GitCommitSummary { export function normalizeGitLogResponse(input: unknown, workdir = ""): GitLogResponse { const source = asObject(input); + const rawHistoryBaseRef = asString(source.historyBaseRef ?? source.history_base_ref); + const hasHistoryRemoteRef = + Object.prototype.hasOwnProperty.call(source, "historyRemoteRef") || + Object.prototype.hasOwnProperty.call(source, "history_remote_ref"); + const rawHistoryRemoteRef = asString(source.historyRemoteRef ?? source.history_remote_ref); return { state: normalizeGitRepositoryState(source.state, workdir), commits: Array.isArray(source.commits) ? source.commits.map(normalizeGitCommitSummary) : [], - historyBaseRef: asString(source.historyBaseRef ?? source.history_base_ref), + historyBaseRef: hasHistoryRemoteRef ? rawHistoryBaseRef : "", + historyRemoteRef: rawHistoryRemoteRef || rawHistoryBaseRef, historyAhead: asNumber(source.historyAhead ?? source.history_ahead), historyBehind: asNumber(source.historyBehind ?? source.history_behind), mergeBase: asString(source.mergeBase ?? source.merge_base), diff --git a/crates/agent-gui/test/tools/git-graph.test.mjs b/crates/agent-gui/test/tools/git-graph.test.mjs index c5a1faf6d..0fe492795 100644 --- a/crates/agent-gui/test/tools/git-graph.test.mjs +++ b/crates/agent-gui/test/tools/git-graph.test.mjs @@ -341,6 +341,54 @@ for (const [surface, graph] of Object.entries(graphModules)) { ]); }); + test(`${surface} git graph keeps current and base refs separate around an upstream merge`, () => { + const result = graph.computeGitGraph( + [ + { sha: "features-tip", parents: ["feature-work"], refs: ["features", "origin/features"] }, + { sha: "feature-work", parents: ["restore-recent"] }, + { + sha: "merge-52", + parents: ["merge-51", "feature-work"], + refs: ["origin/main"], + }, + { sha: "restore-recent", parents: ["merge-51"] }, + { sha: "merge-51", parents: ["main-parent", "sidebar"] }, + ], + { + currentRef: "features", + remoteRef: "origin/features", + baseRef: "origin/main", + }, + ); + + assert.equal(result.maxCols, 3); + const rows = simplifyRows(result.rows); + const merge52 = rows.find((row) => row.sha === "merge-52"); + assert.deepEqual(merge52, { + sha: "merge-52", + parents: ["merge-51", "feature-work"], + commitCol: 1, + commitColor: graph.GRAPH_REF_COLORS.base, + inputLanes: [{ id: "restore-recent", color: graph.GRAPH_REF_COLORS.local }], + outputLanes: [ + { id: "restore-recent", color: graph.GRAPH_REF_COLORS.local }, + { id: "merge-51", color: graph.GRAPH_REF_COLORS.base }, + { id: "feature-work", color: 0 }, + ], + isHead: false, + isMerge: true, + }); + + const restoreRecent = rows.find((row) => row.sha === "restore-recent"); + assert.equal(restoreRecent.commitCol, 0); + assert.equal(restoreRecent.commitColor, graph.GRAPH_REF_COLORS.local); + assert.deepEqual(restoreRecent.inputLanes, [ + { id: "restore-recent", color: graph.GRAPH_REF_COLORS.local }, + { id: "merge-51", color: graph.GRAPH_REF_COLORS.base }, + { id: "feature-work", color: 0 }, + ]); + }); + test(`${surface} git graph normalizes duplicate parent ids`, () => { const result = graph.computeGitGraph([{ sha: "m", parents: ["a", "a", "b", ""] }]); From 38e9519e3aa85bd602f5cf9e4ccfc0ca9ec2f527 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 2 Jun 2026 14:11:31 +0800 Subject: [PATCH 10/10] feat(git-review): improve history ref badges and navigation --- .../web/src/components/icons.tsx | 2 + .../project-tools/GitReviewPanel.tsx | 255 +++++++++++++++-- crates/agent-gateway/web/src/i18n/config.ts | 2 + .../agent-gateway/web/src/lib/git/gitGraph.ts | 6 + .../agent-gui/src-tauri/src/commands/git.rs | 31 ++- crates/agent-gui/src/components/icons.tsx | 2 + .../project-tools/GitReviewPanel.tsx | 259 ++++++++++++++++-- crates/agent-gui/src/i18n/config.ts | 2 + crates/agent-gui/src/lib/git/gitGraph.ts | 6 + 9 files changed, 501 insertions(+), 64 deletions(-) diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index 1313b4763..59c087d5b 100644 --- a/crates/agent-gateway/web/src/components/icons.tsx +++ b/crates/agent-gateway/web/src/components/icons.tsx @@ -85,6 +85,7 @@ import SquareSource from "~icons/lucide/square"; import SquarePenSource from "~icons/lucide/square-pen"; import SunSource from "~icons/lucide/sun"; import TagSource from "~icons/lucide/tag"; +import TargetSource from "~icons/lucide/crosshair"; import TerminalSource from "~icons/lucide/terminal"; import TimerSource from "~icons/lucide/timer"; import TextSelectSource from "~icons/lucide/text-select"; @@ -216,6 +217,7 @@ export const Square = createIcon(SquareSource); export const SquarePen = createIcon(SquarePenSource); export const Sun = createIcon(SunSource); export const Tag = createIcon(TagSource); +export const Target = createIcon(TargetSource); export const Terminal = createIcon(TerminalSource); export const Timer = createIcon(TimerSource); export const TextSelect = createIcon(TextSelectSource); diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index bbc1114b1..303ea6ca2 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -53,6 +53,7 @@ import { MoreHorizontal, RefreshCw, Tag, + Target, Trash2, Upload, X, @@ -1759,16 +1760,179 @@ function graphCircleColor(row: GraphRow) { return graphColor(lane?.color ?? row.commitColor); } -function orderedCommitRefs(refs: readonly string[]) { - const orderedRefs: string[] = []; +type CommitRefKind = "head" | "branch" | "remote" | "tag" | "ref"; + +type CommitRefTagInfo = { + label: string; + kind: CommitRefKind; + title: string; + order: number; + index: number; +}; + +type CommitRefTagOptions = { + remoteName?: string; +}; + +const COMMIT_REF_KIND_ORDER: Record = { + head: 0, + branch: 1, + remote: 2, + tag: 3, + ref: 4, +}; + +const COMMIT_REF_KIND_TITLE: Record = { + head: "HEAD", + branch: "Branch", + remote: "Remote branch", + tag: "Tag", + ref: "Ref", +}; + +function normalizeRefRemoteName(remoteName: string | undefined) { + return remoteName?.trim().replace(/^refs\/remotes\//, "").replace(/\/HEAD$/, "") ?? ""; +} + +function isLikelyRemoteRefLabel(ref: string, remoteName: string | undefined) { + const remote = normalizeRefRemoteName(remoteName); + if (remote && ref.startsWith(`${remote}/`)) return true; + return /^(origin|upstream)\//.test(ref); +} + +function commitRefTagInfo(rawRef: string, index: number, options: CommitRefTagOptions) { + const raw = rawRef.trim(); + if (!raw) return null; + + let ref = raw; + let isHead = false; + let isTag = false; + + if (ref.startsWith("HEAD -> ")) { + isHead = true; + ref = ref.slice("HEAD -> ".length).trim(); + } + + if (ref.startsWith("tag: ")) { + isTag = true; + ref = ref.slice("tag: ".length).trim(); + } + + if (!ref || ref === "HEAD" || ref.endsWith("/HEAD")) return null; + + let kind: CommitRefKind = "ref"; + let label = ref; + if (ref.startsWith("refs/heads/")) { + kind = "branch"; + label = ref.slice("refs/heads/".length); + } else if (ref.startsWith("refs/remotes/")) { + kind = "remote"; + label = ref.slice("refs/remotes/".length); + } else if (ref.startsWith("refs/tags/")) { + kind = "tag"; + label = ref.slice("refs/tags/".length); + } else if (isTag) { + kind = "tag"; + } else if (isLikelyRemoteRefLabel(ref, options.remoteName)) { + kind = "remote"; + } else { + kind = "branch"; + } + + if (!label) return null; + const resolvedKind = isHead ? "head" : kind; + return { + label, + kind: resolvedKind, + title: `${COMMIT_REF_KIND_TITLE[resolvedKind]}: ${label}`, + order: COMMIT_REF_KIND_ORDER[resolvedKind], + index, + } satisfies CommitRefTagInfo; +} + +function orderedCommitRefTags(refs: readonly string[], options: CommitRefTagOptions = {}) { + const orderedRefs: CommitRefTagInfo[] = []; const seenRefs = new Set(); - for (const rawRef of refs) { - const ref = rawRef.trim(); - if (!ref || seenRefs.has(ref)) continue; - seenRefs.add(ref); + refs.forEach((rawRef, index) => { + const ref = commitRefTagInfo(rawRef, index, options); + if (!ref) return; + const key = `${ref.kind}\x00${ref.label}`; + if (seenRefs.has(key)) return; + seenRefs.add(key); orderedRefs.push(ref); + }); + return orderedRefs.sort((left, right) => { + if (left.order !== right.order) return left.order - right.order; + return left.index - right.index; + }); +} + +function orderedCommitRefs(refs: readonly string[], options: CommitRefTagOptions = {}) { + return orderedCommitRefTags(refs, options).map((ref) => ref.label); +} + +function commitRefChipClass(kind: CommitRefKind, selected: boolean) { + const baseClass = + "inline-flex h-5 min-w-0 items-center gap-1 rounded-full border px-1.5 text-[10px] font-semibold leading-[14px] shadow-sm ring-1 ring-inset"; + + if (selected) { + return cn( + baseClass, + "border-accent-foreground/35 bg-accent-foreground/15 text-accent-foreground ring-accent-foreground/20", + ); + } + + switch (kind) { + case "head": + return cn( + baseClass, + "border-emerald-300/60 bg-emerald-50 text-emerald-700 ring-emerald-200/70 dark:border-emerald-300/35 dark:bg-emerald-950/45 dark:text-emerald-200 dark:ring-emerald-300/15", + ); + case "remote": + return cn( + baseClass, + "border-blue-300/60 bg-blue-50 text-blue-700 ring-blue-200/70 dark:border-blue-300/35 dark:bg-blue-950/45 dark:text-blue-200 dark:ring-blue-300/15", + ); + case "tag": + return cn( + baseClass, + "border-amber-300/60 bg-amber-50 text-amber-700 ring-amber-200/70 dark:border-amber-300/35 dark:bg-amber-950/45 dark:text-amber-200 dark:ring-amber-300/15", + ); + case "branch": + return cn( + baseClass, + "border-sky-300/60 bg-sky-50 text-sky-700 ring-sky-200/70 dark:border-sky-300/35 dark:bg-sky-950/45 dark:text-sky-200 dark:ring-sky-300/15", + ); + case "ref": + default: + return cn( + baseClass, + "border-border/70 bg-muted/50 text-muted-foreground ring-border/60", + ); + } +} + +function CommitRefTagIcon({ + kind, + variant, +}: { + kind: CommitRefKind; + variant: "list" | "detail"; +}) { + const className = cn("shrink-0 opacity-85", variant === "detail" ? "h-3 w-3" : "h-2.5 w-2.5"); + switch (kind) { + case "head": + return
+
{commit.subject || commit.shortSha} - +
); @@ -4692,6 +4894,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index a0a50f70b..64e4e89e1 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -334,6 +334,7 @@ export const translations: Record> = { "projectTools.gitReview.listPane": "列表视图", "projectTools.gitReview.detailPane": "详情视图", "projectTools.gitReview.commitHistoryTitle": "提交历史", + "projectTools.gitReview.revealCurrentHistoryItem": "转到当前历史记录项", "projectTools.gitReview.noCommitHistory": "没有提交历史。", "projectTools.gitReview.loadMoreCommits": "加载更多", "projectTools.gitReview.loadingMoreCommits": "正在加载更多...", @@ -1500,6 +1501,7 @@ export const translations: Record> = { "projectTools.gitReview.listPane": "List view", "projectTools.gitReview.detailPane": "Detail view", "projectTools.gitReview.commitHistoryTitle": "Commit History", + "projectTools.gitReview.revealCurrentHistoryItem": "Go to Current History Item", "projectTools.gitReview.noCommitHistory": "No commit history.", "projectTools.gitReview.loadMoreCommits": "Load more", "projectTools.gitReview.loadingMoreCommits": "Loading more...", diff --git a/crates/agent-gateway/web/src/lib/git/gitGraph.ts b/crates/agent-gateway/web/src/lib/git/gitGraph.ts index 9eafd40a0..2f8a847c3 100644 --- a/crates/agent-gateway/web/src/lib/git/gitGraph.ts +++ b/crates/agent-gateway/web/src/lib/git/gitGraph.ts @@ -59,6 +59,12 @@ function cloneLane(lane: GraphLane): GraphLane { function normalizeRef(value: string) { let ref = value.trim(); if (!ref) return ""; + if (ref.startsWith("HEAD -> ")) { + ref = ref.slice("HEAD -> ".length).trim(); + } + if (ref.startsWith("tag: ")) { + ref = ref.slice("tag: ".length).trim(); + } if (ref.startsWith("refs/heads/")) { ref = ref.slice("refs/heads/".length); } else if (ref.startsWith("refs/remotes/")) { diff --git a/crates/agent-gui/src-tauri/src/commands/git.rs b/crates/agent-gui/src-tauri/src/commands/git.rs index 742b95964..e78608e5a 100644 --- a/crates/agent-gui/src-tauri/src/commands/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/git.rs @@ -1266,22 +1266,26 @@ fn clean_git_ref_label(raw: &str) -> Option { if value.is_empty() { return None; } - if let Some((_, target)) = value.split_once(" -> ") { + let mut is_head = false; + let mut is_tag = false; + if let Some((head, target)) = value.split_once(" -> ") { + is_head = head.trim() == "HEAD"; value = target.trim(); } if let Some(stripped) = value.strip_prefix("tag: ") { + is_tag = true; value = stripped.trim(); } - for prefix in ["refs/heads/", "refs/remotes/", "refs/tags/"] { - if let Some(stripped) = value.strip_prefix(prefix) { - value = stripped; - break; - } - } if value.is_empty() || value == "HEAD" || value.ends_with("/HEAD") { return None; } - Some(value.to_string()) + if is_head { + Some(format!("HEAD -> {value}")) + } else if is_tag && !value.starts_with("refs/tags/") { + Some(format!("refs/tags/{value}")) + } else { + Some(value.to_string()) + } } fn parse_git_refs(raw: &str) -> Vec { @@ -2596,12 +2600,19 @@ mod tests { #[test] fn parses_git_log_commits_refs_and_renames() { - let raw = "\x1e0123456789abcdef\x1f0123456\x1ffedcba9\x1fHEAD -> refs/heads/feature, refs/remotes/origin/feature\x1fAlice\x1falice@example.com\x1f2026-05-29T10:11:12+08:00\x1frename file\nR100\0old\tname.txt\0new name.txt\0A\0src/tab\tfile.txt\0"; + let raw = "\x1e0123456789abcdef\x1f0123456\x1ffedcba9\x1fHEAD -> refs/heads/feature, refs/remotes/origin/feature, tag: refs/tags/v1.2.3\x1fAlice\x1falice@example.com\x1f2026-05-29T10:11:12+08:00\x1frename file\nR100\0old\tname.txt\0new name.txt\0A\0src/tab\tfile.txt\0"; let commits = parse_git_log(raw); assert_eq!(commits.len(), 1); let commit = &commits[0]; assert_eq!(commit.short_sha, "0123456"); - assert_eq!(commit.refs, vec!["feature", "origin/feature"]); + assert_eq!( + commit.refs, + vec![ + "HEAD -> refs/heads/feature", + "refs/remotes/origin/feature", + "refs/tags/v1.2.3", + ] + ); assert_eq!(commit.parents, vec!["fedcba9"]); assert_eq!(commit.files.len(), 2); assert_eq!(commit.files[0].status, "R"); diff --git a/crates/agent-gui/src/components/icons.tsx b/crates/agent-gui/src/components/icons.tsx index 9e5d130f1..09e1cf9ef 100644 --- a/crates/agent-gui/src/components/icons.tsx +++ b/crates/agent-gui/src/components/icons.tsx @@ -86,6 +86,7 @@ import SquareSource from "~icons/lucide/square"; import SquarePenSource from "~icons/lucide/square-pen"; import SunSource from "~icons/lucide/sun"; import TagSource from "~icons/lucide/tag"; +import TargetSource from "~icons/lucide/crosshair"; import TerminalSource from "~icons/lucide/terminal"; import Trash2Source from "~icons/lucide/trash-2"; import TextSelectSource from "~icons/lucide/text-select"; @@ -215,6 +216,7 @@ export const Square = createIcon(SquareSource); export const SquarePen = createIcon(SquarePenSource); export const Sun = createIcon(SunSource); export const Tag = createIcon(TagSource); +export const Target = createIcon(TargetSource); export const Terminal = createIcon(TerminalSource); export const TextSelect = createIcon(TextSelectSource); export const Trash2 = createIcon(Trash2Source); diff --git a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx index f5bf84b2f..22bae44de 100644 --- a/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/GitReviewPanel.tsx @@ -53,6 +53,7 @@ import { MoreHorizontal, RefreshCw, Tag, + Target, Trash2, Upload, X, @@ -2063,12 +2064,12 @@ function commitContextRefName( commit: GitCommitSummary, state: Pick, ) { - const refs = orderedCommitRefs(commit.refs); - const remotePrefix = state.remoteName ? `${state.remoteName}/` : ""; + const refs = orderedCommitRefTags(commit.refs, { remoteName: state.remoteName }); return ( - (remotePrefix ? refs.find((ref) => ref.startsWith(remotePrefix)) : "") || - refs.find((ref) => ref.includes("/")) || - refs[0] || + refs.find((ref) => ref.kind === "remote")?.label || + refs.find((ref) => ref.kind === "head")?.label || + refs.find((ref) => ref.kind === "branch")?.label || + refs[0]?.label || commit.shortSha || commit.sha.slice(0, 7) ); @@ -2359,16 +2360,179 @@ function graphCircleColor(row: GraphRow) { return graphColor(lane?.color ?? row.commitColor); } -function orderedCommitRefs(refs: readonly string[]) { - const orderedRefs: string[] = []; +type CommitRefKind = "head" | "branch" | "remote" | "tag" | "ref"; + +type CommitRefTagInfo = { + label: string; + kind: CommitRefKind; + title: string; + order: number; + index: number; +}; + +type CommitRefTagOptions = { + remoteName?: string; +}; + +const COMMIT_REF_KIND_ORDER: Record = { + head: 0, + branch: 1, + remote: 2, + tag: 3, + ref: 4, +}; + +const COMMIT_REF_KIND_TITLE: Record = { + head: "HEAD", + branch: "Branch", + remote: "Remote branch", + tag: "Tag", + ref: "Ref", +}; + +function normalizeRefRemoteName(remoteName: string | undefined) { + return remoteName?.trim().replace(/^refs\/remotes\//, "").replace(/\/HEAD$/, "") ?? ""; +} + +function isLikelyRemoteRefLabel(ref: string, remoteName: string | undefined) { + const remote = normalizeRefRemoteName(remoteName); + if (remote && ref.startsWith(`${remote}/`)) return true; + return /^(origin|upstream)\//.test(ref); +} + +function commitRefTagInfo(rawRef: string, index: number, options: CommitRefTagOptions) { + const raw = rawRef.trim(); + if (!raw) return null; + + let ref = raw; + let isHead = false; + let isTag = false; + + if (ref.startsWith("HEAD -> ")) { + isHead = true; + ref = ref.slice("HEAD -> ".length).trim(); + } + + if (ref.startsWith("tag: ")) { + isTag = true; + ref = ref.slice("tag: ".length).trim(); + } + + if (!ref || ref === "HEAD" || ref.endsWith("/HEAD")) return null; + + let kind: CommitRefKind = "ref"; + let label = ref; + if (ref.startsWith("refs/heads/")) { + kind = "branch"; + label = ref.slice("refs/heads/".length); + } else if (ref.startsWith("refs/remotes/")) { + kind = "remote"; + label = ref.slice("refs/remotes/".length); + } else if (ref.startsWith("refs/tags/")) { + kind = "tag"; + label = ref.slice("refs/tags/".length); + } else if (isTag) { + kind = "tag"; + } else if (isLikelyRemoteRefLabel(ref, options.remoteName)) { + kind = "remote"; + } else { + kind = "branch"; + } + + if (!label) return null; + const resolvedKind = isHead ? "head" : kind; + return { + label, + kind: resolvedKind, + title: `${COMMIT_REF_KIND_TITLE[resolvedKind]}: ${label}`, + order: COMMIT_REF_KIND_ORDER[resolvedKind], + index, + } satisfies CommitRefTagInfo; +} + +function orderedCommitRefTags(refs: readonly string[], options: CommitRefTagOptions = {}) { + const orderedRefs: CommitRefTagInfo[] = []; const seenRefs = new Set(); - for (const rawRef of refs) { - const ref = rawRef.trim(); - if (!ref || seenRefs.has(ref)) continue; - seenRefs.add(ref); + refs.forEach((rawRef, index) => { + const ref = commitRefTagInfo(rawRef, index, options); + if (!ref) return; + const key = `${ref.kind}\x00${ref.label}`; + if (seenRefs.has(key)) return; + seenRefs.add(key); orderedRefs.push(ref); + }); + return orderedRefs.sort((left, right) => { + if (left.order !== right.order) return left.order - right.order; + return left.index - right.index; + }); +} + +function orderedCommitRefs(refs: readonly string[], options: CommitRefTagOptions = {}) { + return orderedCommitRefTags(refs, options).map((ref) => ref.label); +} + +function commitRefChipClass(kind: CommitRefKind, selected: boolean) { + const baseClass = + "inline-flex h-5 min-w-0 items-center gap-1 rounded-full border px-1.5 text-[10px] font-semibold leading-[14px] shadow-sm ring-1 ring-inset"; + + if (selected) { + return cn( + baseClass, + "border-accent-foreground/35 bg-accent-foreground/15 text-accent-foreground ring-accent-foreground/20", + ); + } + + switch (kind) { + case "head": + return cn( + baseClass, + "border-emerald-300/60 bg-emerald-50 text-emerald-700 ring-emerald-200/70 dark:border-emerald-300/35 dark:bg-emerald-950/45 dark:text-emerald-200 dark:ring-emerald-300/15", + ); + case "remote": + return cn( + baseClass, + "border-blue-300/60 bg-blue-50 text-blue-700 ring-blue-200/70 dark:border-blue-300/35 dark:bg-blue-950/45 dark:text-blue-200 dark:ring-blue-300/15", + ); + case "tag": + return cn( + baseClass, + "border-amber-300/60 bg-amber-50 text-amber-700 ring-amber-200/70 dark:border-amber-300/35 dark:bg-amber-950/45 dark:text-amber-200 dark:ring-amber-300/15", + ); + case "branch": + return cn( + baseClass, + "border-sky-300/60 bg-sky-50 text-sky-700 ring-sky-200/70 dark:border-sky-300/35 dark:bg-sky-950/45 dark:text-sky-200 dark:ring-sky-300/15", + ); + case "ref": + default: + return cn( + baseClass, + "border-border/70 bg-muted/50 text-muted-foreground ring-border/60", + ); + } +} + +function CommitRefTagIcon({ + kind, + variant, +}: { + kind: CommitRefKind; + variant: "list" | "detail"; +}) { + const className = cn("shrink-0 opacity-85", variant === "detail" ? "h-3 w-3" : "h-2.5 w-2.5"); + switch (kind) { + case "head": + return
+
{commit.subject || commit.shortSha} - +
); @@ -5082,6 +5284,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 25e698d71..808648e1e 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -347,6 +347,7 @@ export const translations: Record> = { "projectTools.gitReview.listPane": "列表视图", "projectTools.gitReview.detailPane": "详情视图", "projectTools.gitReview.commitHistoryTitle": "提交历史", + "projectTools.gitReview.revealCurrentHistoryItem": "转到当前历史记录项", "projectTools.gitReview.noCommitHistory": "没有提交历史。", "projectTools.gitReview.loadMoreCommits": "加载更多", "projectTools.gitReview.loadingMoreCommits": "正在加载更多...", @@ -1554,6 +1555,7 @@ export const translations: Record> = { "projectTools.gitReview.listPane": "List view", "projectTools.gitReview.detailPane": "Detail view", "projectTools.gitReview.commitHistoryTitle": "Commit History", + "projectTools.gitReview.revealCurrentHistoryItem": "Go to Current History Item", "projectTools.gitReview.noCommitHistory": "No commit history.", "projectTools.gitReview.loadMoreCommits": "Load more", "projectTools.gitReview.loadingMoreCommits": "Loading more...", diff --git a/crates/agent-gui/src/lib/git/gitGraph.ts b/crates/agent-gui/src/lib/git/gitGraph.ts index 9eafd40a0..2f8a847c3 100644 --- a/crates/agent-gui/src/lib/git/gitGraph.ts +++ b/crates/agent-gui/src/lib/git/gitGraph.ts @@ -59,6 +59,12 @@ function cloneLane(lane: GraphLane): GraphLane { function normalizeRef(value: string) { let ref = value.trim(); if (!ref) return ""; + if (ref.startsWith("HEAD -> ")) { + ref = ref.slice("HEAD -> ".length).trim(); + } + if (ref.startsWith("tag: ")) { + ref = ref.slice("tag: ".length).trim(); + } if (ref.startsWith("refs/heads/")) { ref = ref.slice("refs/heads/".length); } else if (ref.startsWith("refs/remotes/")) {