diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index afad0b3f..eff8d9a6 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 ec3d8f80..a481741b 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 506ac244..1f43a0af 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 1e81bf2f..0baf1455 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 d8685c23..ae89d487 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 a95038a1..0b55cf6b 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/test/webui/web-remote-input.test.mjs b/crates/agent-gateway/test/webui/web-remote-input.test.mjs new file mode 100644 index 00000000..46def815 --- /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 bf5bf3da..5f28d708 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/package.json b/crates/agent-gateway/web/package.json index 4ee6887a..4f6f9411 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 c2c8d978..cdb71bed 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 f882ea2f..f5c179b7 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,16 @@ 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); + const workspaceEditorRequestIdRef = useRef(0); const [terminalSessions, setTerminalSessions] = useState([]); const { confirm: requestConfirmDialog, dialog: confirmDialog } = useConfirmDialog(); const terminalSessionsVersionRef = useRef(0); @@ -890,8 +893,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 +941,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 +969,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 +1181,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 +1241,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 +1283,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 +1343,7 @@ export default function App() { if (!conversationIdValue) { return; } - conversationRuntimeCacheRef.current.set( - conversationIdValue, - buildVisibleRuntimeEntry(), - ); + conversationRuntimeCacheRef.current.set(conversationIdValue, buildVisibleRuntimeEntry()); }, [buildVisibleRuntimeEntry], ); @@ -1367,22 +1360,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 +1561,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 +1599,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 +1728,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 +1772,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 +1802,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 +1983,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 +2047,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 +2129,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 +2153,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 +2263,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 +2311,7 @@ export default function App() { } setProjectRenamingId(null); setProjectRenameDraft(""); - }, [ - commitWorkspaceProjectRename, - projectRenameDraft, - projectRenamingId, - workspaceProjects, - ]); + }, [commitWorkspaceProjectRename, projectRenameDraft, projectRenamingId, workspaceProjects]); const handleCancelWorkspaceProjectRename = useCallback(() => { setProjectRenamingId(null); @@ -2358,8 +2325,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 +2424,8 @@ export default function App() { setSettingsSyncError(null); }); - void api.getSettings() + void api + .getSettings() .then((payload) => { if (!cancelled) { applyGatewaySettings(payload); @@ -2499,8 +2466,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 +2555,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 +2575,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 +2587,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 +2621,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 +2649,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 +2754,8 @@ export default function App() { return; } if ( - resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ) !== conversationIdValue + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) !== + conversationIdValue ) { return; } @@ -2835,11 +2794,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 +3014,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 +3107,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 +3149,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 +3231,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 +3256,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 +3285,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 +3381,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 +3432,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 +3447,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 +3458,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 +3495,8 @@ export default function App() { protectedConversationId && protectedConversationSummary === null && (requestedConversationId === "" || requestedConversationId === protectedConversationId) && - ( - currentConversationId === protectedConversationId || - currentSelectedHistoryId === protectedConversationId - ) + (currentConversationId === protectedConversationId || + currentSelectedHistoryId === protectedConversationId) ) { return; } @@ -3617,7 +3545,7 @@ export default function App() { ? currentConversationId : currentConversationId && currentChatMessages.length > 0 ? "" - : conversations[0]?.id ?? ""); + : (conversations[0]?.id ?? "")); if (!preferredConversationId) { if (!currentConversationId) { @@ -3633,13 +3561,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 +3613,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 +3637,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 +3672,7 @@ export default function App() { const effectiveConversationId = !isLocalDraftConversationId(targetId) && targetId !== "" ? targetId - : ( - visibleConversationId !== "" && - !isLocalDraftConversationId(visibleConversationId) - ) + : visibleConversationId !== "" && !isLocalDraftConversationId(visibleConversationId) ? visibleConversationId : targetId; @@ -3854,13 +3767,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 +3867,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 +3894,7 @@ export default function App() { normalizedStatus, isCompaction, ); - setLiveConversationStreamStatus( - activeConversationId, - normalizedStatus, - isCompaction, - ); + setLiveConversationStreamStatus(activeConversationId, normalizedStatus, isCompaction); updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, toolStatus: normalizedStatus, @@ -4035,8 +3946,7 @@ export default function App() { } blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); if ( - pendingDraftConversationMigrationRef.current?.draftConversationId === - activeConversationId + pendingDraftConversationMigrationRef.current?.draftConversationId === activeConversationId ) { pendingDraftConversationMigrationRef.current = null; } @@ -4051,17 +3961,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 +4049,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 +4071,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 +4180,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 +4228,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 +4342,7 @@ export default function App() { }); const currentConversationId = conversationIdRef.current.trim(); - if ( - currentConversationId && - currentConversationId !== targetConversationId - ) { + if (currentConversationId && currentConversationId !== targetConversationId) { cacheVisibleConversationRuntime(currentConversationId); } @@ -4451,10 +4355,7 @@ export default function App() { } if ( cachedRuntime && - ( - cachedRuntime.isSending || - localRunningConversationIdsRef.current.has(targetConversationId) - ) + (cachedRuntime.isSending || localRunningConversationIdsRef.current.has(targetConversationId)) ) { invalidateHistoryLoad(); markVisibleConversationRevision(); @@ -4599,9 +4500,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 +4572,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 +4690,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 (isUploadingFilesRef.current) { + setChatError(translate("chat.upload.uploading", settings.locale)); + return; } - if (ignoredForLimit > 0) { - warnings.push( + 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 +4936,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 +5156,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 +5182,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 +5198,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 +5266,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 +5277,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 +5295,42 @@ 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; + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorMounted(true); + setWorkspaceEditorOpen(true); + setWorkspaceEditorOpenRequest({ + id: workspaceEditorRequestIdRef.current, + projectPathKey: terminalProjectPathKey, + workdir: terminalProjectPath, + path, + }); + }, + [terminalProjectPath, terminalProjectPathKey], + ); + 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 @@ -5518,8 +5438,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 +5465,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 +5476,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 +5496,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 +5523,7 @@ export default function App() { } } - if ( - api && - isObservingRemoteLiveConversation && - nextDisplayedConversationId !== "" - ) { + if (api && isObservingRemoteLiveConversation && nextDisplayedConversationId !== "") { attachVisibleConversationLiveStream(nextDisplayedConversationId, api); } }, [ @@ -5633,20 +5541,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 +5582,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 +5594,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 +5615,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 +5654,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 +5680,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 +5776,7 @@ export default function App() {
-
- 正在同步桌面端设置... -
+
正在同步桌面端设置...
@@ -5894,521 +5799,549 @@ 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; + } + 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); } - } 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} - {statusError ?
{statusError}
: null} - {settingsSyncError ?
{settingsSyncError}
: null} - {chatError && chatMessages.length === 0 && !hasDetachedSelection ? ( -
{chatError}
- ) : null} - -
-
- - 0} - onOpenSettings={openSettings} - hasMoreHistory={selectedHistoryHasMore} - isLoadingMoreHistory={loadingOlderHistory} - onLoadFullHistory={selectedHistoryHasMore ? handleLoadFullHistory : undefined} +
+
+ + 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 ? ( -
+
+ )} +
+ {workspaceEditorMounted ? ( + + {translate("workspaceEditor.loading", settings.locale)}
- ) : null} - - - - )} - + } + > + setWorkspaceEditorOpen(false)} + onClose={() => { + setWorkspaceEditorOpen(false); + setWorkspaceEditorMounted(false); + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorOpenRequest(null); + setWorkspaceEditorCloseRequestId(0); + }} + /> + + ) : null} + {terminalClient ? ( + onFileTreeOpenChange={(open) => { setSettings((prev) => updateProjectToolsFileTreeOpen(prev, terminalProjectPathKey, open), - ) - } + ); + }} onFileTreeStateChange={(patch) => setSettings((prev) => updateProjectToolsFileTreeProjectState(prev, terminalProjectPathKey, patch), @@ -6487,6 +6417,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/chat/ChatHistorySidebar.tsx b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx index 69e10ea9..7b9b83ff 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", )} > diff --git a/crates/agent-gateway/web/src/components/icons.tsx b/crates/agent-gateway/web/src/components/icons.tsx index e9982888..59c087d5 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"; @@ -80,9 +85,12 @@ 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"; 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 +113,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 +149,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 +199,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); @@ -198,9 +217,12 @@ 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); 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 05eb2ec2..303ea6ca 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, @@ -52,6 +53,7 @@ import { MoreHorizontal, RefreshCw, Tag, + Target, Trash2, Upload, X, @@ -473,16 +475,26 @@ type ParsedDiffStat = { type DiffViewKind = "branch" | "workingTree"; type GitReviewMode = "changes" | "history"; type GitReviewStackedPane = "list" | "detail"; +type GitHistoryMarkerKind = Extract; +type GitHistoryGraphState = Pick< + GitLogResponse, + "historyBaseRef" | "historyRemoteRef" | "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; } | { @@ -554,7 +566,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 +1551,7 @@ function DiffContent(props: {
{ writeTextToClipboard(selectionContextMenu.selectedText); closeSelectionContextMenu(); @@ -1745,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
+
); } + 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.historyRemoteRef, + ); + const title = refLabel ? `${label} ${refLabel}` : label; + return ( +
+
+ + + {label} + + {refLabel ? ( + + {refLabel} + + ) : null} +
+
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -4432,7 +4790,7 @@ export function GitReviewPanel(props: { 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 (
{commit.subject || commit.shortSha} - +
); @@ -4532,6 +4894,7 @@ export function GitReviewPanel(props: { @@ -4589,7 +4952,7 @@ export function GitReviewPanel(props: { (historyContextMenu.kind === "commit" || historyContextFile) ? (
event.stopPropagation()} onContextMenu={(event) => { @@ -4640,7 +5003,7 @@ export function GitReviewPanel(props: { {t("projectTools.gitReview.openOnGithub")} -
+
-
+
-
+
-
+
); }, - [cwd, openContextMenu, setProjectState, state, syncFileTreeState, t, toggleDirectory], + [ + cwd, + onOpenEditableFile, + openContextMenu, + setProjectState, + state, + syncFileTreeState, + t, + toggleDirectory, + ], ); const actionPlaceholder = useMemo(() => { @@ -930,17 +946,35 @@ export function ProjectFileTreePanel(props: { {contextMenu ? (
{ event.preventDefault(); event.stopPropagation(); }} > + {contextKind === "file" ? ( + <> + +
+ + ) : null} -
+
-
+
); @@ -1803,7 +1845,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
{disabledMessage}
- ) : showFirstOpenChooser ? ( + ) : showProjectToolsChooser ? (

@@ -1825,8 +1867,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 +1915,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { {t("projectTools.loading")}
) : null} - {error ? ( -
{error}
- ) : null} + {error ?
{error}
: null}
) : ( <> @@ -1883,6 +1935,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { onInitializedChange={setFileTreeInitialized} onSyncStateChange={onFileTreeStateChange} onInsertFileMention={onInsertFileMention} + onOpenEditableFile={onOpenEditableFile} />
) : null} @@ -1929,16 +1982,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 00000000..95e6d6cf --- /dev/null +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -0,0 +1,1121 @@ +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; + isOpen: boolean; + finalCloseRequested?: boolean; + theme: "light" | "dark"; + onHide: () => void; + 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, + isOpen, + finalCloseRequested = false, + theme, + onHide, + 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 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(); + }, 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 ?? ""; + }); + return next; + }); + }, + [disposeModel], + ); + + 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]); + + const hideOverlay = useCallback(() => { + if (finalCloseRequested) { + requestCloseOverlay(); + return; + } + setPendingDialog(null); + setContextMenu(null); + finishHide(); + }, [finalCloseRequested, finishHide, 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(); + 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]); + + 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 (!isOpen) return; + 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); + }, [isOpen, 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 70290623..64e4e89e 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": "请输入仓库地址。", @@ -306,6 +310,8 @@ export const translations: Record> = { "projectTools.gitReview.labelBase": "基线", "projectTools.gitReview.labelAhead": "领先", "projectTools.gitReview.labelBehind": "落后", + "projectTools.gitReview.outgoingChanges": "传出的更改", + "projectTools.gitReview.incomingChanges": "传入的更改", "projectTools.gitReview.labelStaged": "已暂存", "projectTools.gitReview.labelUnstaged": "未暂存", "projectTools.gitReview.labelUntracked": "未跟踪", @@ -328,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": "正在加载更多...", @@ -336,7 +343,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 +381,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 +413,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 +478,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 +531,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 +570,8 @@ export const translations: Record> = { "settings.memoryOrganizerClusterSummaries": "分组总结", "settings.memoryOrganizerTrimmedProtocol": "裁剪后的模型协议", "settings.memoryOrganizerManualPreview": "手动整理预览", - "settings.memoryOrganizerManualPreviewDescription": "默认选中客户端判定为安全的建议,确认后才会写入记忆。", + "settings.memoryOrganizerManualPreviewDescription": + "默认选中客户端判定为安全的建议,确认后才会写入记忆。", "settings.memoryOrganizerApplySelected": "应用选中建议", "settings.memoryOrganizerApplied": "选中建议已应用。", "settings.memoryOrganizerPartiallyApplied": "选中建议已部分应用;请查看失败项。", @@ -606,7 +658,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 +685,8 @@ export const translations: Record> = { "settings.workdirWarning": "Agent 模式需要先选择项目,否则无法执行文件工具。", "settings.workdirOpenFailed": "打开目录选择器失败:", "settings.systemTools": "自定义系统工具", - "settings.systemToolsDesc": "这里仅展示用户自定义的系统工具;选中的工具会在 Agent 模式下注册,供模型在对话中调用。", + "settings.systemToolsDesc": + "这里仅展示用户自定义的系统工具;选中的工具会在 Agent 模式下注册,供模型在对话中调用。", "settings.noSystemTools": "暂无可用的自定义系统工具", /* ── Settings Providers ── */ @@ -757,9 +811,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 +884,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 +897,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 +974,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 +1040,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 +1076,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 +1190,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 +1206,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 +1239,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 +1355,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 +1397,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 +1406,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 +1436,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.", @@ -1400,6 +1477,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", @@ -1422,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...", @@ -1430,7 +1510,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 +1529,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 +1549,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 +1565,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 +1582,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 +1646,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 +1665,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 +1702,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 +1746,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 +1814,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 +1828,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 +1838,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 +1849,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 +1866,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 +1929,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 +1942,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 +1952,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 +1966,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 +1982,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 +1997,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 +2010,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 +2068,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 +2164,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 +2193,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 +2216,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 +2231,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 +2272,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 +2288,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 +2304,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 +2323,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 +2333,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 ed8862bf..5fde2ca6 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -1720,3 +1720,95 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { -webkit-user-select: none; 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; + 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 5ce63ff8..ec18d5c1 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 7824bbc0..7164bb08 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/git/gitGraph.ts b/crates/agent-gateway/web/src/lib/git/gitGraph.ts index 6b82fe3a..2f8a847c 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 { @@ -50,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/")) { @@ -94,6 +109,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 +134,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 +315,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 +364,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 1cb9da8c..c53293fe 100644 --- a/crates/agent-gateway/web/src/lib/git/types.ts +++ b/crates/agent-gateway/web/src/lib/git/types.ts @@ -81,6 +81,11 @@ export type GitCommitSummary = { export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; + historyBaseRef: string; + historyRemoteRef: string; + historyAhead: number; + historyBehind: number; + mergeBase: string; }; export type GitCommitDetails = { @@ -282,9 +287,19 @@ 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: 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-gateway/web/src/lib/monacoNls.ts b/crates/agent-gateway/web/src/lib/monacoNls.ts new file mode 100644 index 00000000..17f5ac61 --- /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/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 4b046cc4..41d85387 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 d0fefa57..07f3a4db 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 00000000..8fff7053 --- /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-gateway/web/src/shims/tauriCore.ts b/crates/agent-gateway/web/src/shims/tauriCore.ts index a030f84e..5f237fe3 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 a6628651..a3733c5c 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); @@ -1829,6 +1839,10 @@ html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="cl backdrop-filter: blur(18px); } + html[data-liveagent-webui="gateway"] .workspace-code-editor-overlay { + z-index: 60; + } + html[data-liveagent-webui="gateway"] .project-tools-panel-inner { min-width: 0; } diff --git a/crates/agent-gateway/web/src/vite-env.d.ts b/crates/agent-gateway/web/src/vite-env.d.ts index c53f345d..1644a4bf 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 e797ae7f..c5b53e43 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 0a103c1d..5525148b 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 77cb394a..3136152a 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/commands/git.rs b/crates/agent-gui/src-tauri/src/commands/git.rs index 0b7ddbfc..e78608e5 100644 --- a/crates/agent-gui/src-tauri/src/commands/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/git.rs @@ -123,6 +123,11 @@ pub struct GitCommitSummary { 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, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1261,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 { @@ -1379,6 +1388,134 @@ fn local_only_commit_shas(repo_root: &str, cloud_ref: &str) -> HashSet { .unwrap_or_default() } +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(); + } + } + 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 { + 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 +1565,22 @@ pub(crate) fn git_log_sync( return Ok(GitLogResponse { state, commits: Vec::new(), + history_base_ref: String::new(), + history_remote_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_remote_ref: String::new(), + history_ahead: 0, + history_behind: 0, + merge_base: String::new(), }); } let limit = limit @@ -1455,18 +1602,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 = if !state.upstream.trim().is_empty() { - state.upstream.clone() - } else { + 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() }; - if !review_ref.trim().is_empty() && review_ref != "HEAD" { - args.push(review_ref); - } + 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); @@ -1483,7 +1627,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 { @@ -1494,7 +1637,17 @@ 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, + history_remote_ref: review_ref, + history_ahead, + history_behind, + merge_base, + }) } pub(crate) fn git_commit_details_sync( @@ -2447,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"); @@ -3013,6 +3173,53 @@ 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, ""); + 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); + 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 { @@ -3269,6 +3476,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"]); @@ -3284,7 +3492,17 @@ 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, 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); let local_commit = history .commits .iter() @@ -3305,6 +3523,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-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 683a5162..5f2a15e2 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 ac171a21..87c0d532 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 28252635..2a214884 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 759425f8..09e1cf9e 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"; @@ -83,9 +86,12 @@ 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"; 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 +197,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); @@ -207,8 +216,11 @@ 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); +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 1bbb44fa..22bae44d 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, @@ -52,6 +53,7 @@ import { MoreHorizontal, RefreshCw, Tag, + Target, Trash2, Upload, X, @@ -79,6 +81,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 +486,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; @@ -516,16 +523,26 @@ type ParsedDiffStat = { type DiffViewKind = "branch" | "workingTree"; type GitReviewMode = "changes" | "history"; type GitReviewStackedPane = "list" | "detail"; +type GitHistoryMarkerKind = Extract; +type GitHistoryGraphState = Pick< + GitLogResponse, + "historyBaseRef" | "historyRemoteRef" | "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; } | { @@ -605,7 +622,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 = { @@ -1368,6 +1388,8 @@ function DiffContent(props: { const updateDiffHorizontalScrollbar = useCallback(() => { const root = rootRef.current; + if (isProjectToolsPanelResizing(root)) return; + const trackWidth = diffHorizontalScrollbarTrackRef.current?.clientWidth ?? scrollViewportRef.current?.clientWidth ?? @@ -1500,6 +1522,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 () => { @@ -1507,6 +1530,7 @@ function DiffContent(props: { window.cancelAnimationFrame(animationFrame); } window.removeEventListener("resize", refreshTargets); + window.removeEventListener(PROJECT_TOOLS_RESIZE_END_EVENT, refreshTargets); mutationObserver?.disconnect(); detachTargets(); resizeObserver?.disconnect(); @@ -1840,7 +1864,7 @@ function DiffContent(props: {
{ writeTextToClipboard(selectionContextMenu.selectedText); closeSelectionContextMenu(); @@ -2040,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) ); @@ -2210,7 +2234,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) => [ @@ -2227,7 +2255,7 @@ function gitHistorySignature(state: GitRepositoryState, commits: GitCommitSummar ].join("\x1e"), ) .join("\x1f"); - return `${gitRepositoryStateSignature(state)}\x1d${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) { @@ -2332,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
+
); } + 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.historyRemoteRef, + ); + const title = refLabel ? `${label} ${refLabel}` : label; + return ( +
+
+ + + {label} + + {refLabel ? ( + + {refLabel} + + ) : null} +
+
+ ); + } if (row.type === "file") { const TypeIcon = getFileTypeIcon(row.file.path, "file"); const fileSelected = @@ -4809,7 +5172,7 @@ export function GitReviewPanel(props: { 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 (
{commit.subject || commit.shortSha} - +
); @@ -4917,6 +5284,7 @@ export function GitReviewPanel(props: { @@ -4976,7 +5344,7 @@ export function GitReviewPanel(props: { (historyContextMenu.kind === "commit" || historyContextFile) ? (
event.stopPropagation()} onContextMenu={(event) => { @@ -5027,7 +5395,7 @@ export function GitReviewPanel(props: { {t("projectTools.gitReview.openOnGithub")} -
+
-
+
-
+
-
+
); }, - [cwd, openContextMenu, setProjectState, state, syncFileTreeState, t, toggleDirectory], + [ + cwd, + onOpenEditableFile, + openContextMenu, + setProjectState, + state, + syncFileTreeState, + t, + toggleDirectory, + ], ); const actionPlaceholder = useMemo(() => { @@ -930,17 +946,35 @@ export function ProjectFileTreePanel(props: { {contextMenu ? (
{ event.preventDefault(); event.stopPropagation(); }} > + {contextKind === "file" ? ( + <> + +
+ + ) : null} -
+
-
+
-
{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 +1669,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { {t("projectTools.loading")}
) : null} - {error ? ( -
{error}
- ) : null} + {error ?
{error}
: null}
) : ( <> @@ -1658,6 +1689,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { onInitializedChange={setFileTreeInitialized} onSyncStateChange={onFileTreeStateChange} onInsertFileMention={onInsertFileMention} + onOpenEditableFile={onOpenEditableFile} />
) : null} @@ -1704,16 +1736,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 00000000..0176b8ce --- /dev/null +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -0,0 +1,1123 @@ +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; + isOpen: boolean; + finalCloseRequested?: boolean; + theme: "light" | "dark"; + onHide: () => void; + 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, + isOpen, + finalCloseRequested = false, + theme, + onHide, + 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 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(); + }, 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 ?? ""; + }); + return next; + }); + }, + [disposeModel], + ); + + 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]); + + const hideOverlay = useCallback(() => { + if (finalCloseRequested) { + requestCloseOverlay(); + return; + } + setPendingDialog(null); + setContextMenu(null); + finishHide(); + }, [finalCloseRequested, finishHide, 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(); + 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]); + + 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 (!isOpen) return; + 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); + }, [isOpen, 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 54efb748..808648e1 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -323,6 +323,8 @@ export const translations: Record> = { "projectTools.gitReview.labelBase": "基线", "projectTools.gitReview.labelAhead": "领先", "projectTools.gitReview.labelBehind": "落后", + "projectTools.gitReview.outgoingChanges": "传出的更改", + "projectTools.gitReview.incomingChanges": "传入的更改", "projectTools.gitReview.labelStaged": "已暂存", "projectTools.gitReview.labelUnstaged": "未暂存", "projectTools.gitReview.labelUntracked": "未跟踪", @@ -345,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": "正在加载更多...", @@ -423,11 +426,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 +1234,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 +1249,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 +1259,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 +1408,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", @@ -1488,6 +1531,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", @@ -1510,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...", @@ -1590,11 +1636,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 +2301,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 307d7366..c4cb0d4e 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -1990,3 +1990,95 @@ body > div:not(#root) [data-streamdown="mermaid"] svg { -webkit-user-select: none; 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; + 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/git/gitGraph.ts b/crates/agent-gui/src/lib/git/gitGraph.ts index 6b82fe3a..2f8a847c 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 { @@ -50,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/")) { @@ -94,6 +109,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 +134,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 +315,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 +364,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 ffab07e7..feaa3b92 100644 --- a/crates/agent-gui/src/lib/git/types.ts +++ b/crates/agent-gui/src/lib/git/types.ts @@ -81,6 +81,11 @@ export type GitCommitSummary = { export type GitLogResponse = { state: GitRepositoryState; commits: GitCommitSummary[]; + historyBaseRef: string; + historyRemoteRef: string; + historyAhead: number; + historyBehind: number; + mergeBase: string; }; export type GitCommitDetails = { @@ -286,9 +291,19 @@ 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: 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/lib/monacoNls.ts b/crates/agent-gui/src/lib/monacoNls.ts new file mode 100644 index 00000000..acc98a79 --- /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/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 4e624d1c..58c37e65 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/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 6973998c..021e2eb7 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,16 @@ 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); + const workspaceEditorRequestIdRef = useRef(0); const [projectTerminalSessions, setProjectTerminalSessions] = useState([]); const [remoteRuntimeStatus, setRemoteRuntimeStatus] = useState(() => buildFallbackGatewayStatus(settings.remote), @@ -1430,6 +1466,42 @@ 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; + setWorkspaceEditorCleanupPending(false); + setWorkspaceEditorMounted(true); + setWorkspaceEditorOpen(true); + setWorkspaceEditorOpenRequest({ + id: workspaceEditorRequestIdRef.current, + projectPathKey: terminalProjectPathKey, + workdir: terminalProjectPath, + path, + }); + }, + [terminalProjectPath, terminalProjectPathKey], + ); + 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([]); @@ -4106,298 +4178,331 @@ 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)); + }} onFileTreeStateChange={(patch) => setSettings((prev) => updateProjectToolsFileTreeProjectState(prev, terminalProjectPathKey, patch), @@ -4460,6 +4565,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/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index 3e721e52..f4ad670a 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 00000000..8fff7053 --- /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/src/vite-env.d.ts b/crates/agent-gui/src/vite-env.d.ts index c1fe4fd6..bb0d7f11 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" { diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 31dc17e0..b65186a3 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 00000000..3d09ebc9 --- /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); +}); diff --git a/crates/agent-gui/test/tools/git-graph.test.mjs b/crates/agent-gui/test/tools/git-graph.test.mjs index bacd8d37..0fe49279 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"] }, @@ -275,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", ""] }]);