From f00f17f93867828b42e1070fd818cbb22924d345 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Tue, 14 Jul 2026 16:46:33 +0800 Subject: [PATCH 1/4] feat(chat): add attachments and improve agent conversations --- docs/api.md | 34 + docs/api.zh.md | 34 + internal/agent/agents_instructions.go | 19 +- internal/agent/agents_instructions_test.go | 12 +- internal/api/attachments.go | 182 +++++ internal/api/csgclaw_channel_test.go | 173 +++++ internal/api/handler.go | 81 +- internal/api/handler_test.go | 66 ++ internal/api/participant_bridge.go | 66 +- internal/api/router.go | 1 + internal/apitypes/types.go | 55 +- internal/channelbridge/codexbridge/bridge.go | 85 ++- .../channelbridge/codexbridge/bridge_test.go | 145 ++++ .../channelbridge/codexbridge/sse_client.go | 66 +- internal/channelbridge/types.go | 47 +- internal/im/asset_store.go | 689 ++++++++++++++++++ internal/im/participant_bridge.go | 3 + internal/im/participant_bridge_test.go | 71 ++ internal/im/service.go | 87 ++- internal/im/service_test.go | 206 ++++++ internal/im/session_store.go | 81 +- internal/runtime/codex/appserver_client.go | 6 + internal/runtime/codex/appserver_events.go | 11 +- internal/runtime/codex/appserver_manager.go | 118 ++- .../runtime/codex/appserver_manager_test.go | 113 ++- internal/runtime/codex/runtime.go | 13 +- internal/runtime/codex/runtime_test.go | 78 +- web/app/src/api/im.ts | 10 + .../ConversationAttachments.tsx | 145 ++++ .../ConversationPane/ConversationComposer.tsx | 261 ++++--- .../ConversationMessageActions.tsx | 102 +++ .../ConversationMessageList.tsx | 78 +- .../ConversationPane/ConversationPane.css | 560 +++++++------- .../ConversationThreadPanel.tsx | 91 ++- .../ConversationPane/ConversationThreads.css | 104 ++- .../ConversationPane/attachmentFiles.ts | 13 + .../business/ConversationPane/types.ts | 7 + .../MessageContent/MessageContent.tsx | 2 +- .../components/ui/Icons/LongMessageIcons.tsx | 16 +- web/app/src/components/ui/Popover/Popover.css | 26 + web/app/src/components/ui/Popover/Popover.tsx | 52 ++ web/app/src/components/ui/Popover/index.ts | 3 + web/app/src/components/ui/index.ts | 1 + .../workspace/useConversationController.ts | 187 ++++- web/app/src/models/attachments.test.ts | 52 ++ web/app/src/models/attachments.ts | 131 ++++ web/app/src/models/composer.ts | 46 +- web/app/src/models/conversations.ts | 8 +- .../ConversationPane/ConversationPane.tsx | 12 + .../FloatingChat/FloatingChat.module.css | 21 +- .../FloatingChat/FloatingChatPanel.tsx | 12 + .../CreateModelProviderModal.tsx | 12 +- .../WorkspaceRows/WorkspaceRows.tsx | 11 +- web/app/src/shared/i18n/messages.ts | 22 + web/app/tests/api/im.test.ts | 26 +- .../ConversationAttachments.test.tsx | 45 ++ .../ConversationComposerConnectors.test.tsx | 107 ++- .../ConversationMessageActions.test.tsx | 35 + .../components/ConversationPane.test.tsx | 19 + .../tests/components/MessageContent.test.tsx | 7 + .../components/WorkspaceSidebar.test.tsx | 13 +- .../hooks/useConversationController.test.tsx | 60 ++ web/app/tests/legacy-contract.test.ts | 3 + web/app/tests/models/composer.test.ts | 44 ++ web/app/tests/models/conversations.test.ts | 18 + 65 files changed, 4186 insertions(+), 718 deletions(-) create mode 100644 internal/api/attachments.go create mode 100644 internal/im/asset_store.go create mode 100644 web/app/src/components/business/ConversationPane/ConversationAttachments.tsx create mode 100644 web/app/src/components/business/ConversationPane/ConversationMessageActions.tsx create mode 100644 web/app/src/components/business/ConversationPane/attachmentFiles.ts create mode 100644 web/app/src/components/ui/Popover/Popover.css create mode 100644 web/app/src/components/ui/Popover/Popover.tsx create mode 100644 web/app/src/components/ui/Popover/index.ts create mode 100644 web/app/src/models/attachments.test.ts create mode 100644 web/app/src/models/attachments.ts create mode 100644 web/app/tests/components/ConversationAttachments.test.tsx create mode 100644 web/app/tests/components/ConversationMessageActions.test.tsx diff --git a/docs/api.md b/docs/api.md index 66981dce..1da272f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -886,6 +886,32 @@ Notes: - `relates_to.rel_type` currently supports `m.thread`; the root must be a top-level message in the same room - A thread reply also publishes `thread.updated` +- To send attachments, use `multipart/form-data` with a `payload` JSON part containing the same fields and one or more `files` parts. +- Attachment-only messages are valid when at least one file is present. +- Each returned message can include `attachments` with `id`, `name`, `kind`, `media_type`, `size_bytes`, `sha256`, `created_at`, `download_url`, optional `preview_url`, optional image dimensions, and optional `workspace_path` for agent-facing deliveries. + +Multipart example: + +```text +payload={"room_id":"room-1","sender_id":"manager","content":""} +files=@diagram.png;type=image/png +``` + +### `GET /api/v1/attachments/{id}` + +Downloads a stored chat attachment by attachment ID. + +The `download_url` returned in attachment metadata includes an attachment-scoped capability token and can be used directly by browsers and agents. + +Callers may instead request the bare path with the configured server Bearer token. + +The endpoint serves the original bytes with the stored media type and `X-Content-Type-Options: nosniff`. + +Treat the capability URL as a secret and avoid sharing it outside the room context. + +Image attachments are served inline. + +Other file attachments are served with attachment disposition. ### `POST /api/v1/rooms/{id}/threads` @@ -1112,6 +1138,10 @@ thread was started. Runtime/LLM bridges use it as prompt context; it is not a li of thread replies. PicoClaw-native clients can use `context.topic_id` as the same thread/session identifier. +Events can include an `attachments` array with the same message attachment metadata returned by the message APIs. + +For CSGClaw agents, the server also attempts to copy each attachment into the target agent workspace and sets `workspace_path` when that copy succeeds. + ### `POST /api/v1/channels/csgclaw/participants/{id}/messages` Sends a message as the specified local CSGClaw participant. @@ -1132,6 +1162,10 @@ that IM thread. When all are omitted, the response is sent as a top-level room/D message; the server does not infer a thread from the participant's most recent room event. +This endpoint also accepts the same multipart attachment format as `POST /api/v1/messages`. + +Use a `payload` JSON part for the participant message fields and one or more `files` parts for generated files. + PicoClaw outbound message shape is also accepted: ```json diff --git a/docs/api.zh.md b/docs/api.zh.md index d9d5e59e..01083c2e 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -867,6 +867,32 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess - 发送 thread reply 时传入 `relates_to: {"rel_type":"m.thread","event_id":""}` - `relates_to.rel_type` 当前支持 `m.thread`;root 必须是同一 room 内的顶层消息 - thread reply 还会发布 `thread.updated` +- 发送附件时使用 `multipart/form-data`,其中 `payload` JSON part 包含同样字段,`files` part 可以出现一次或多次。 +- 至少带有一个文件时允许发送纯附件消息。 +- 返回的 message 可以包含 `attachments`,字段包括 `id`、`name`、`kind`、`media_type`、`size_bytes`、`sha256`、`created_at`、`download_url`、可选的 `preview_url`、可选图片尺寸,以及面向 agent 投递时可选的 `workspace_path`。 + +Multipart 示例: + +```text +payload={"room_id":"room-1","sender_id":"manager","content":""} +files=@diagram.png;type=image/png +``` + +### `GET /api/v1/attachments/{id}` + +按 attachment ID 下载已存储的聊天附件。 + +附件元数据中的 `download_url` 包含附件级 capability token,浏览器和 agent 可以直接使用。 + +调用方也可以使用配置的服务端 Bearer token 请求不带 capability token 的基础路径。 + +该接口会使用存储的 media type 返回原始字节,并设置 `X-Content-Type-Options: nosniff`。 + +请将 capability URL 视为敏感信息,不要在 room 上下文之外共享。 + +图片附件以内联方式返回。 + +其他文件附件以下载方式返回。 ### `POST /api/v1/rooms/{id}/threads` @@ -1087,6 +1113,10 @@ data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"ro prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 可以把 `context.topic_id` 当作同一个 thread/session 标识。 +事件可以包含 `attachments` 数组,字段与 message API 返回的附件元数据一致。 + +对于 CSGClaw agents,服务端还会尝试把每个附件复制到目标 agent workspace,并在复制成功时设置 `workspace_path`。 + ### `POST /api/v1/channels/csgclaw/participants/{id}/messages` 以指定本地 CSGClaw participant 身份发送消息。 @@ -1106,6 +1136,10 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 响应会作为 room/DM 顶层消息发送;服务端不会根据 participant 在房间中最近收到的 事件推断 thread。 +该接口也接受与 `POST /api/v1/messages` 相同的 multipart 附件格式。 + +使用 `payload` JSON part 传 participant 消息字段,并用一个或多个 `files` part 传生成的文件。 + 也接受 PicoClaw outbound message 形态: ```json diff --git a/internal/agent/agents_instructions.go b/internal/agent/agents_instructions.go index 88ee9c5a..05b71665 100644 --- a/internal/agent/agents_instructions.go +++ b/internal/agent/agents_instructions.go @@ -43,7 +43,24 @@ const managerRuntimeConnectorInstructions = `### GitHub Connector Access - Never print, echo, log, write, persist, or include the token value in prompts, messages, UI text, state files, snapshots, or ` + "`AGENTS.md`" + ` edits. - Do not rely on connector tokens from environment variables such as ` + "`GITHUB_TOKEN`" + `; connector credentials are intentionally fetched on demand so reconnects and refreshes work without restarting the Manager. - Do not treat an empty result from an external Codex GitHub app connector as proof that the CSGClaw GitHub connector has no repository access. -- If the credential API returns ` + "`400`" + `, ` + "`401`" + `, or ` + "`403`" + `, tell the user to reconnect the CSGClaw GitHub OAuth connector or check connector access policy.` +- If the credential API returns ` + "`400`" + `, ` + "`401`" + `, or ` + "`403`" + `, tell the user to reconnect the CSGClaw GitHub OAuth connector or check connector access policy. + +### Historical Attachment Recovery + +- Treat files under ` + "`.csgclaw/attachments/`" + ` as runtime-local cache copies, not as the durable attachment index. +- When the user refers to a previously uploaded file that is absent from the current workspace, query CSGClaw message history before claiming the file is unavailable or asking the user to upload it again. +- Use the current ` + "`channel`" + ` and ` + "`room_id`" + ` from the hidden channel context with ` + "`csgclaw-cli message list --channel --room-id `" + `. +- Filter the JSON locally to attachment-bearing messages and retain ` + "`id`" + `, ` + "`name`" + `, ` + "`media_type`" + `, ` + "`size_bytes`" + `, ` + "`sha256`" + `, ` + "`created_at`" + `, the originating message ID, and the originating message text. +- Use a structured pipeline that excludes capability-bearing download URLs, such as ` + "`csgclaw-cli message list --channel --room-id | jq '[.[] as $message | ($message.attachments // [])[] | {id, name, kind, media_type, size_bytes, sha256, created_at, message_id: $message.id, message_text: $message.content}]'`" + `. +- Match candidates using the filename, the originating message text, and recency. +- If exactly one candidate matches, download it by stable attachment ID into ` + "`.csgclaw/retrieved/-`" + ` with ` + "`GET $CSGCLAW_BASE_URL/api/v1/attachments/`" + ` and ` + "`Authorization: Bearer $CSGCLAW_ACCESS_TOKEN`" + `. +- A safe download command is ` + "`curl -fsS -H \"Authorization: Bearer ${CSGCLAW_ACCESS_TOKEN:?}\" \"$CSGCLAW_BASE_URL/api/v1/attachments/\" --output \".csgclaw/retrieved/-\"`" + `. +- Use the stable attachment ID for authenticated downloads instead of copying a capability-bearing ` + "`download_url`" + ` into commands, logs, or responses. +- Verify the downloaded file against its ` + "`sha256`" + ` before reading it. +- If multiple candidates plausibly match, show the user a concise candidate list instead of guessing. +- If the current room has no match and the user clearly refers to an upload from another conversation, list rooms and inspect only the relevant candidate rooms. +- Do not search the web for a referenced upload, rely only on ` + "`find`" + ` in the current workspace, or request a re-upload until durable CSGClaw history has been checked. +- Never print, echo, or include ` + "`CSGCLAW_ACCESS_TOKEN`" + ` or a capability token in tool output, logs, prompts, or responses.` func renderAgentsInstructionsBlock(instructions, managedInstructions string) string { instructions = strings.TrimSpace(instructions) diff --git a/internal/agent/agents_instructions_test.go b/internal/agent/agents_instructions_test.go index f78fb303..6fb5e568 100644 --- a/internal/agent/agents_instructions_test.go +++ b/internal/agent/agents_instructions_test.go @@ -112,6 +112,14 @@ func TestRenderRuntimeAgentsInstructionsBlockAddsManagerConnectorRulesOnlyForMan "Do not rely on connector tokens from environment variables", "Do not treat an empty result from an external Codex GitHub app connector as proof", "reconnect the CSGClaw GitHub OAuth connector", + "Historical Attachment Recovery", + "csgclaw-cli message list --channel --room-id ", + "jq '[.[] as $message | ($message.attachments // [])[]", + "runtime-local cache copies, not as the durable attachment index", + "GET $CSGCLAW_BASE_URL/api/v1/attachments/", + "curl -fsS -H \"Authorization: Bearer ${CSGCLAW_ACCESS_TOKEN:?}\"", + "Use the stable attachment ID for authenticated downloads", + "until durable CSGClaw history has been checked", } { if !strings.Contains(manager, want) { t.Fatalf("manager runtime instructions missing %q in %q", want, manager) @@ -119,7 +127,9 @@ func TestRenderRuntimeAgentsInstructionsBlockAddsManagerConnectorRulesOnlyForMan } worker := RenderRuntimeAgentsInstructionsBlock("agent-worker", "Stay concise.") - if strings.Contains(worker, "GitHub Connector Access") || strings.Contains(worker, "`GITHUB_TOKEN`") { + if strings.Contains(worker, "GitHub Connector Access") || + strings.Contains(worker, "Historical Attachment Recovery") || + strings.Contains(worker, "`GITHUB_TOKEN`") { t.Fatalf("worker runtime instructions include manager connector guidance: %q", worker) } } diff --git a/internal/api/attachments.go b/internal/api/attachments.go new file mode 100644 index 00000000..fe171767 --- /dev/null +++ b/internal/api/attachments.go @@ -0,0 +1,182 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + + "csgclaw/internal/im" +) + +const multipartAttachmentMemory = 8 * 1024 * 1024 + +var errAttachmentPayloadTooLarge = errors.New("attachment payload is too large") + +func parseCreateMessageHTTP(w http.ResponseWriter, r *http.Request) (createMessageRequest, error) { + var req createMessageRequest + uploads, err := decodeMessagePayload(w, r, &req) + if err != nil { + return createMessageRequest{}, err + } + req.Attachments = uploads + return req, nil +} + +func parseParticipantSendMessageHTTP(w http.ResponseWriter, r *http.Request) (im.ParticipantSendMessageRequest, error) { + var req im.ParticipantSendMessageRequest + uploads, err := decodeMessagePayload(w, r, &req) + if err != nil { + return im.ParticipantSendMessageRequest{}, err + } + req.Attachments = uploads + return req, nil +} + +func decodeMessagePayload(w http.ResponseWriter, r *http.Request, dst any) ([]im.MessageAttachmentUpload, error) { + if !isMultipartRequest(r) { + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + return nil, fmt.Errorf("decode request: %w", err) + } + return nil, nil + } + r.Body = http.MaxBytesReader(w, r.Body, im.MaxAttachmentMessageBytes+1024*1024) + if err := r.ParseMultipartForm(multipartAttachmentMemory); err != nil { + return nil, fmt.Errorf("decode multipart request: %w", err) + } + defer r.MultipartForm.RemoveAll() + payload := strings.TrimSpace(r.FormValue("payload")) + if payload == "" { + return nil, fmt.Errorf("payload is required") + } + if err := json.Unmarshal([]byte(payload), dst); err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + uploads, err := readMultipartAttachmentUploads(r.MultipartForm) + if err != nil { + return nil, err + } + return uploads, nil +} + +func isMultipartRequest(r *http.Request) bool { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + return err == nil && strings.EqualFold(mediaType, "multipart/form-data") +} + +func readMultipartAttachmentUploads(form *multipart.Form) ([]im.MessageAttachmentUpload, error) { + if form == nil || len(form.File) == 0 { + return nil, nil + } + var headers []*multipart.FileHeader + for _, field := range []string{"files", "file"} { + headers = append(headers, form.File[field]...) + } + if len(headers) == 0 { + return nil, nil + } + if len(headers) > im.MaxAttachmentsPerMessage { + return nil, fmt.Errorf("too many attachments: max %d", im.MaxAttachmentsPerMessage) + } + uploads := make([]im.MessageAttachmentUpload, 0, len(headers)) + total := int64(0) + for _, header := range headers { + name, err := multipartOriginalFilename(header) + if err != nil { + return nil, err + } + data, err := readMultipartAttachment(header) + if err != nil { + return nil, err + } + total += int64(len(data)) + if total > im.MaxAttachmentMessageBytes { + return nil, fmt.Errorf("%w: attachments exceed %d bytes per message", errAttachmentPayloadTooLarge, im.MaxAttachmentMessageBytes) + } + uploads = append(uploads, im.MessageAttachmentUpload{ + Name: name, + MediaType: header.Header.Get("Content-Type"), + Data: data, + }) + } + return uploads, nil +} + +func multipartOriginalFilename(header *multipart.FileHeader) (string, error) { + if header == nil { + return "", fmt.Errorf("attachment is missing") + } + _, params, err := mime.ParseMediaType(header.Header.Get("Content-Disposition")) + if err != nil { + return "", fmt.Errorf("decode attachment filename: %w", err) + } + if name := strings.TrimSpace(params["filename"]); name != "" { + return name, nil + } + return header.Filename, nil +} + +func readMultipartAttachment(header *multipart.FileHeader) ([]byte, error) { + if header == nil { + return nil, fmt.Errorf("attachment is missing") + } + file, err := header.Open() + if err != nil { + return nil, fmt.Errorf("open attachment %q: %w", header.Filename, err) + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, im.MaxAttachmentFileBytes+1)) + if err != nil { + return nil, fmt.Errorf("read attachment %q: %w", header.Filename, err) + } + if len(data) > im.MaxAttachmentFileBytes { + return nil, fmt.Errorf("%w: attachment %q exceeds %d bytes", errAttachmentPayloadTooLarge, header.Filename, im.MaxAttachmentFileBytes) + } + return data, nil +} + +func writeMessagePayloadError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + var maxBytesError *http.MaxBytesError + if errors.Is(err, errAttachmentPayloadTooLarge) || errors.Is(err, multipart.ErrMessageTooLarge) || errors.As(err, &maxBytesError) { + status = http.StatusRequestEntityTooLarge + } + http.Error(w, err.Error(), status) +} + +func (h *Handler) handleAttachmentByID(w http.ResponseWriter, r *http.Request) { + if h == nil || h.im == nil { + http.Error(w, "im service is not configured", http.StatusServiceUnavailable) + return + } + id := pathValue(r, "id") + if strings.TrimSpace(id) == "" { + http.NotFound(w, r) + return + } + file, err := h.im.AttachmentFile(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if !h.validateServerAccessToken(r.Header.Get("Authorization")) && !file.AuthorizesDownloadToken(r.URL.Query().Get("token")) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", file.MediaType) + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + disposition := "attachment" + if strings.HasPrefix(strings.ToLower(file.MediaType), "image/") { + disposition = "inline" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": file.SafeName})) + http.ServeFile(w, r, file.Path) +} diff --git a/internal/api/csgclaw_channel_test.go b/internal/api/csgclaw_channel_test.go index 9757cbcc..8f2d53fc 100644 --- a/internal/api/csgclaw_channel_test.go +++ b/internal/api/csgclaw_channel_test.go @@ -1,9 +1,14 @@ package api import ( + "bytes" "encoding/json" + "io" + "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" + "path/filepath" "strings" "testing" "time" @@ -81,6 +86,174 @@ func TestHandleCsgclawChannelRoutesMirrorLocalCollections(t *testing.T) { }) } +func TestHandleCsgclawMessageMultipartAttachmentAndDownload(t *testing.T) { + imSvc, err := im.NewServiceFromPath(filepath.Join(t.TempDir(), "im", "state.json")) + if err != nil { + t.Fatalf("NewServiceFromPath() error = %v", err) + } + worker, _, err := imSvc.EnsureAgentUser(im.EnsureAgentUserRequest{ID: "worker", Name: "worker", Role: "worker"}) + if err != nil { + t.Fatalf("EnsureAgentUser() error = %v", err) + } + room, err := imSvc.CreateRoom(im.CreateRoomRequest{ + Title: "Uploads", + CreatorID: "user-admin", + MemberIDs: []string{worker.ID}, + }) + if err != nil { + t.Fatalf("CreateRoom() error = %v", err) + } + srv := &Handler{im: imSvc, participantBridge: im.NewParticipantBridge(""), serverAccessToken: "secret"} + fileBytes := []byte("hello from an uploaded note") + + body, contentType := multipartMessageBodyForTest(t, map[string]any{ + "room_id": room.ID, + "sender_id": "user-admin", + "content": "", + }, "files", "plan.txt", "text/plain", fileBytes) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/messages", body) + req.Header.Set("Content-Type", contentType) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create message status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var msg im.Message + if err := json.NewDecoder(rec.Body).Decode(&msg); err != nil { + t.Fatalf("decode message: %v", err) + } + if len(msg.Attachments) != 1 { + t.Fatalf("attachments = %+v, want one uploaded file", msg.Attachments) + } + att := msg.Attachments[0] + if att.Name != "plan.txt" || att.Kind != "file" || att.MediaType != "text/plain" { + t.Fatalf("attachment = %+v, want sanitized file metadata", att) + } + + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, att.DownloadURL, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("capability download status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Body.String(); got != string(fileBytes) { + t.Fatalf("capability download body = %q, want original upload", got) + } + + bareDownloadURL, _, _ := strings.Cut(att.DownloadURL, "?") + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, bareDownloadURL, nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("download status without capability or auth = %d, want %d", rec.Code, http.StatusUnauthorized) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, bareDownloadURL, nil) + req.Header.Set("Authorization", "Bearer secret") + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("download status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Body.String(); got != string(fileBytes) { + t.Fatalf("download body = %q, want original upload", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff header = %q, want nosniff", got) + } + if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox") { + t.Fatalf("Content-Security-Policy = %q, want sandbox", got) + } + + body, contentType = multipartMessageBodyForTest(t, map[string]any{ + "room_id": room.ID, + "sender_id": "user-admin", + "content": "", + }, "files", "../secret.txt", "text/plain", []byte("secret")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/messages", body) + req.Header.Set("Content-Type", contentType) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unsafe") { + t.Fatalf("unsafe filename status = %d, body=%q, want 400 unsafe filename", rec.Code, rec.Body.String()) + } + + oversized := bytes.Repeat([]byte("x"), im.MaxAttachmentFileBytes+1) + body, contentType = multipartMessageBodyForTest(t, map[string]any{ + "room_id": room.ID, + "sender_id": "user-admin", + "content": "", + }, "files", "large.bin", "application/octet-stream", oversized) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/messages", body) + req.Header.Set("Content-Type", contentType) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized attachment status = %d, body=%q, want %d", rec.Code, rec.Body.String(), http.StatusRequestEntityTooLarge) + } + + body, contentType = multipartMessageBodyForTest(t, map[string]any{ + "room_id": room.ID, + "text": "", + "thread_root_id": msg.ID, + }, "files", "report.txt", "text/plain", []byte("generated report")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/worker/messages", body) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", contentType) + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("participant multipart status = %d, body=%q, want %d", rec.Code, rec.Body.String(), http.StatusOK) + } + var participantResponse map[string]string + if err := json.NewDecoder(rec.Body).Decode(&participantResponse); err != nil { + t.Fatalf("decode participant response: %v", err) + } + messages, err := imSvc.ListMessagesWithOptions(room.ID, im.ListMessagesOptions{IncludeThreadReplies: true}) + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + var participantMessage im.Message + for _, message := range messages { + if message.ID == participantResponse["message_id"] { + participantMessage = message + break + } + } + if len(participantMessage.Attachments) != 1 || participantMessage.Attachments[0].Name != "report.txt" { + t.Fatalf("participant message = %+v, want generated report attachment", participantMessage) + } + if participantMessage.RelatesTo == nil || participantMessage.RelatesTo.EventID != msg.ID { + t.Fatalf("participant message relation = %+v, want thread root %q", participantMessage.RelatesTo, msg.ID) + } +} + +func multipartMessageBodyForTest(t *testing.T, payload map[string]any, fieldName, filename, mediaType string, data []byte) (io.Reader, string) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + payloadWriter, err := writer.CreateFormField("payload") + if err != nil { + t.Fatalf("CreateFormField(payload) error = %v", err) + } + if err := json.NewEncoder(payloadWriter).Encode(payload); err != nil { + t.Fatalf("encode payload: %v", err) + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="`+fieldName+`"; filename="`+filename+`"`) + header.Set("Content-Type", mediaType) + part, err := writer.CreatePart(header) + if err != nil { + t.Fatalf("CreatePart(file) error = %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("write multipart file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + return bytes.NewReader(body.Bytes()), writer.FormDataContentType() +} + func TestHandleCsgclawUsersShowsHumanDescriptionAndBoundChannels(t *testing.T) { participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ ID: "admin", diff --git a/internal/api/handler.go b/internal/api/handler.go index 792dd562..7a5cf5b1 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -671,12 +671,13 @@ func bootstrapRuntimeKind(runtime string) string { } type createMessageRequest struct { - RoomID string `json:"room_id"` - SenderID string `json:"sender_id"` - Content string `json:"content"` - MentionID string `json:"mention_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - RelatesTo *im.MessageRelation `json:"relates_to,omitempty"` + RoomID string `json:"room_id"` + SenderID string `json:"sender_id"` + Content string `json:"content"` + MentionID string `json:"mention_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + RelatesTo *im.MessageRelation `json:"relates_to,omitempty"` + Attachments []im.MessageAttachmentUpload `json:"attachments,omitempty"` } type addRoomMembersRequest struct { @@ -969,7 +970,7 @@ func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) { return } h.publishUpdatedAgentUser(updated) - writeJSON(w, http.StatusOK, h.presentAgentResponse(updated)) + writeJSON(w, http.StatusOK, h.presentAgentForRequest(r, updated)) case http.MethodDelete: var err error if h.participant != nil { @@ -2220,9 +2221,9 @@ func (h *Handler) handleCreateMessage(w http.ResponseWriter, r *http.Request) { if !ok { return } - var req createMessageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + req, err := parseCreateMessageHTTP(w, r) + if err != nil { + writeMessagePayloadError(w, err) return } @@ -2499,35 +2500,52 @@ func (h *Handler) presentAgentsForRequest(r *http.Request, items []agent.Agent) for _, item := range items { out = append(out, h.presentAgentResponse(item)) } - if h == nil || h.participant == nil { - return out + var byAgent map[string][]apitypes.Participant + if h != nil && h.participant != nil { + byAgent = participantsByAgentID(h.presentParticipants(h.participant.List(participant.ListOptions{}))) } - byAgent := participantsByAgentID(h.presentParticipants(h.participant.List(participant.ListOptions{}))) for i := range out { - items := byAgent[out[i].ID] - out[i].ParticipantIDs, out[i].ParticipantNames = participantSummaries(items) - out[i].UserID, out[i].UserName = agentLocalUserSummary(items) + participantItems := byAgent[out[i].ID] + out[i].ParticipantIDs, out[i].ParticipantNames = participantSummaries(participantItems) + out[i].UserID, out[i].UserName = agentLocalUserSummary(participantItems) if includeParticipants(r) { - out[i].Participants = items + out[i].Participants = participantItems } + h.backfillAgentLocalUser(&out[i]) } return out } func (h *Handler) presentAgentForRequest(r *http.Request, item agent.Agent) agentResponse { resp := h.presentAgentResponse(item) - if h == nil || h.participant == nil { - return resp - } - items := h.presentParticipants(h.participant.List(participant.ListOptions{AgentID: item.ID})) - resp.ParticipantIDs, resp.ParticipantNames = participantSummaries(items) - resp.UserID, resp.UserName = agentLocalUserSummary(items) - if includeParticipants(r) { - resp.Participants = items + if h != nil && h.participant != nil { + items := h.presentParticipants(h.participant.List(participant.ListOptions{AgentID: item.ID})) + resp.ParticipantIDs, resp.ParticipantNames = participantSummaries(items) + resp.UserID, resp.UserName = agentLocalUserSummary(items) + if includeParticipants(r) { + resp.Participants = items + } } + h.backfillAgentLocalUser(&resp) return resp } +func (h *Handler) backfillAgentLocalUser(resp *agentResponse) { + if h == nil || h.im == nil || resp == nil || strings.TrimSpace(resp.UserID) != "" { + return + } + expectedUserID := localUserIDFromAny(resp.ID) + if expectedUserID == "" { + return + } + user, ok := h.im.User(expectedUserID) + if !ok || localUserIDFromAny(user.ID) != expectedUserID { + return + } + resp.UserID = strings.TrimSpace(user.ID) + resp.UserName = strings.TrimSpace(user.Name) +} + func (h *Handler) presentAgentResponse(item agent.Agent) agentResponse { resp := presentAgent(item) resp.RuntimeOptionSchemas = h.runtimeOptionSchemasForKind(item.RuntimeKind) @@ -2931,12 +2949,13 @@ func (r createMessageRequest) toServiceRequest() (im.CreateMessageRequest, error } return im.CreateMessageRequest{ - RoomID: roomID, - SenderID: r.SenderID, - Content: r.Content, - MentionID: r.MentionID, - Metadata: r.Metadata, - RelatesTo: r.RelatesTo, + RoomID: roomID, + SenderID: r.SenderID, + Content: r.Content, + MentionID: r.MentionID, + Metadata: r.Metadata, + RelatesTo: r.RelatesTo, + Attachments: r.Attachments, }, nil } diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 636aeb65..12ccdb60 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -869,6 +869,72 @@ func TestHandleAgentsListExposesLinkedLocalUser(t *testing.T) { } } +func TestHandleAgentResponsesExposeLinkedLocalUserWithoutCSGClawParticipant(t *testing.T) { + svc := mustNewSeededService(t, []agent.Agent{ + {ID: "agent-dahym7", Name: "qa", Role: agent.RoleWorker, CreatedAt: time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)}, + }) + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: im.AdminUserID, + Users: []im.User{ + {ID: im.AdminUserID, Name: "admin", Role: "admin"}, + {ID: "user-dahym7", Name: "qa", Role: agent.RoleWorker, Avatar: "avatar/3D-5.png"}, + }, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "pt-dahym7-feishu", + Channel: participant.ChannelFeishu, + Type: participant.TypeAgent, + Name: "qa", + AgentID: "agent-dahym7", + ChannelUserKind: participant.ChannelUserKindAppID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + + srv := &Handler{svc: svc, im: imSvc, participant: participantSvc} + tests := []struct { + name string + method string + path string + body string + listResult bool + }{ + {name: "list", method: http.MethodGet, path: "/api/v1/agents?include_participants=true", listResult: true}, + {name: "get", method: http.MethodGet, path: "/api/v1/agents/agent-dahym7?include_participants=true"}, + {name: "patch", method: http.MethodPatch, path: "/api/v1/agents/agent-dahym7", body: `{"description":"updated"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var got apitypes.Agent + if tt.listResult { + var items []apitypes.Agent + if err := json.NewDecoder(rec.Body).Decode(&items); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(items) != 1 { + t.Fatalf("len(agents) = %d, want 1; body=%s", len(items), rec.Body.String()) + } + got = items[0] + } else if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.UserID != "user-dahym7" || got.UserName != "qa" { + t.Fatalf("agent user = %q/%q, want user-dahym7/qa; body=%s", got.UserID, got.UserName, rec.Body.String()) + } + if tt.method == http.MethodGet && (len(got.Participants) != 1 || got.Participants[0].Channel != participant.ChannelFeishu) { + t.Fatalf("participants = %+v, want only the existing Feishu participant", got.Participants) + } + }) + } +} + func TestHandleAgentsListHydratesStatusFromSandboxInfo(t *testing.T) { t.Setenv("HOME", t.TempDir()) dir := t.TempDir() diff --git a/internal/api/participant_bridge.go b/internal/api/participant_bridge.go index ee11fb04..50ac5032 100644 --- a/internal/api/participant_bridge.go +++ b/internal/api/participant_bridge.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "path/filepath" "strings" "time" @@ -122,12 +123,70 @@ func (h *Handler) enqueueParticipantMessageEventForBridgeTarget(room im.Room, se } deliveryRoom := roomForParticipantBridgeTarget(room, target) deliveryMessage := messageForParticipantBridgeTarget(message, target) + deliveryMessage.Attachments = h.materializeAttachmentsForParticipant(deliveryMessage.Attachments, deliveryRoom.ID, deliveryMessage.ID, target.bridgeID) + deliveryRoom = h.materializeThreadContextAttachmentsForParticipant(deliveryRoom, deliveryMessage, target.bridgeID) if strings.TrimSpace(text) != "" { return h.participantBridge.EnqueueMessageEventWithText(deliveryRoom, sender, deliveryMessage, target.bridgeID, text) } return h.participantBridge.EnqueueMessageEvent(deliveryRoom, sender, deliveryMessage, target.bridgeID) } +func (h *Handler) materializeThreadContextAttachmentsForParticipant(room im.Room, message im.Message, bridgeID string) im.Room { + if message.RelatesTo == nil || message.RelatesTo.RelType != im.RelationTypeThread { + return room + } + rootID := strings.TrimSpace(message.RelatesTo.EventID) + if rootID == "" { + return room + } + out := room + out.Threads = append([]im.ThreadState(nil), room.Threads...) + for threadIndex := range out.Threads { + if strings.TrimSpace(out.Threads[threadIndex].RootMessageID) != rootID { + continue + } + out.Threads[threadIndex].Context = append([]im.Message(nil), out.Threads[threadIndex].Context...) + for messageIndex := range out.Threads[threadIndex].Context { + contextMessage := &out.Threads[threadIndex].Context[messageIndex] + contextMessage.Attachments = h.materializeAttachmentsForParticipant( + contextMessage.Attachments, + room.ID, + contextMessage.ID, + bridgeID, + ) + } + break + } + return out +} + +func (h *Handler) materializeAttachmentsForParticipant(attachments []im.MessageAttachment, roomID, messageID, bridgeID string) []im.MessageAttachment { + if len(attachments) == 0 || h == nil || h.im == nil || h.svc == nil { + return append([]im.MessageAttachment(nil), attachments...) + } + agentID := h.runtimeAgentIDForBridgeID(bridgeID) + if strings.TrimSpace(agentID) == "" { + return append([]im.MessageAttachment(nil), attachments...) + } + workspaceRoot, err := h.svc.WorkspaceRoot(agentID) + if err != nil { + slog.Warn("resolve attachment workspace failed", "agent_id", agentID, "participant_id", bridgeID, "error", err) + return append([]im.MessageAttachment(nil), attachments...) + } + relativeDir := filepath.ToSlash(filepath.Join(".csgclaw", "attachments", roomID, messageID)) + out := make([]im.MessageAttachment, 0, len(attachments)) + for _, attachment := range attachments { + materialized, err := h.im.MaterializeAttachment(attachment.ID, workspaceRoot, relativeDir) + if err != nil { + slog.Warn("materialize attachment failed", "attachment_id", attachment.ID, "agent_id", agentID, "error", err) + out = append(out, attachment) + continue + } + out = append(out, materialized) + } + return out +} + func (h *Handler) participantBridgeTargetsForRoom(room im.Room) []participantBridgeTarget { targets := make([]participantBridgeTarget, 0, len(room.Members)) seen := make(map[string]struct{}, len(room.Members)) @@ -616,9 +675,9 @@ func (h *Handler) handleParticipantSendMessage(w http.ResponseWriter, r *http.Re http.Error(w, "im service is not configured", http.StatusServiceUnavailable) return } - var req im.ParticipantSendMessageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + req, err := parseParticipantSendMessageHTTP(w, r) + if err != nil { + writeMessagePayloadError(w, err) return } roomID := req.ResolvedRoomID() @@ -643,6 +702,7 @@ func (h *Handler) handleParticipantSendMessage(w http.ResponseWriter, r *http.Re MessageID: messageID, ThreadRootID: threadRootID, Metadata: req.Metadata, + Attachments: req.Attachments, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/internal/api/router.go b/internal/api/router.go index 137783aa..0ab3a380 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -126,6 +126,7 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { }) r.Get("/bootstrap", h.getIMBootstrap) r.Get("/events", h.getIMEvents) + r.Get("/attachments/{id}", h.handleAttachmentByID) r.Route("/rooms", func(r chi.Router) { r.Get("/", h.listRooms) r.Post("/", h.createRoom) diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go index eae41c28..760df88f 100644 --- a/internal/apitypes/types.go +++ b/internal/apitypes/types.go @@ -61,25 +61,48 @@ type Mention struct { } type Message struct { - ID string `json:"id"` - SenderID string `json:"sender_id"` - Kind string `json:"kind,omitempty"` - Content string `json:"content"` - Event *EventPayload `json:"event,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - Mentions []Mention `json:"mentions"` - RelatesTo *MessageRelation `json:"relates_to,omitempty"` - Thread *ThreadSummary `json:"thread,omitempty"` + ID string `json:"id"` + SenderID string `json:"sender_id"` + Kind string `json:"kind,omitempty"` + Content string `json:"content"` + Event *EventPayload `json:"event,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + Mentions []Mention `json:"mentions"` + RelatesTo *MessageRelation `json:"relates_to,omitempty"` + Thread *ThreadSummary `json:"thread,omitempty"` + Attachments []MessageAttachment `json:"attachments,omitempty"` +} + +type MessageAttachment struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + MediaType string `json:"media_type"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` + DownloadURL string `json:"download_url"` + PreviewURL string `json:"preview_url,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` +} + +type MessageAttachmentUpload struct { + Name string `json:"name,omitempty"` + MediaType string `json:"media_type,omitempty"` + Data []byte `json:"-"` } type CreateMessageRequest struct { - RoomID string `json:"room_id"` - SenderID string `json:"sender_id"` - Content string `json:"content"` - MentionID string `json:"mention_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - RelatesTo *MessageRelation `json:"relates_to,omitempty"` + RoomID string `json:"room_id"` + SenderID string `json:"sender_id"` + Content string `json:"content"` + MentionID string `json:"mention_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + RelatesTo *MessageRelation `json:"relates_to,omitempty"` + Attachments []MessageAttachmentUpload `json:"attachments,omitempty"` } type MessageRelation struct { diff --git a/internal/channelbridge/codexbridge/bridge.go b/internal/channelbridge/codexbridge/bridge.go index f07fe606..3d77b930 100644 --- a/internal/channelbridge/codexbridge/bridge.go +++ b/internal/channelbridge/codexbridge/bridge.go @@ -814,7 +814,7 @@ func (w *worker) sessionID(ctx context.Context, evt BotEvent) (string, error) { } func (w *worker) promptText(evt BotEvent) string { - text := strings.TrimSpace(evt.Text) + text := joinHiddenContexts(strings.TrimSpace(evt.Text), formatAttachmentManifest(evt.Attachments)) key := conversationKey(evt) contextText := joinHiddenContexts( @@ -964,7 +964,7 @@ func joinHiddenContexts(values ...string) string { func formatHiddenChannelContext(binding Binding, evt BotEvent) string { channel := strings.TrimSpace(evt.Channel) - if channel == "" || strings.EqualFold(channel, localChannel) { + if channel == "" { return "" } roomID := strings.TrimSpace(evt.RoomID) @@ -1004,7 +1004,8 @@ func formatHiddenThreadContext(context *BotThreadContext) string { } for _, message := range context.Context { content := strings.Join(strings.Fields(strings.TrimSpace(message.Content)), " ") - if content == "" { + attachments := formatInlineAttachmentSummary(message.Attachments) + if content == "" && attachments == "" { continue } b.WriteString("- ") @@ -1020,11 +1021,89 @@ func formatHiddenThreadContext(context *BotThreadContext) string { b.WriteString("[root] ") } b.WriteString(content) + if attachments != "" { + if content != "" { + b.WriteByte(' ') + } + b.WriteString(attachments) + } b.WriteByte('\n') } return strings.TrimSpace(b.String()) } +func formatAttachmentManifest(attachments []MessageAttachment) string { + if len(attachments) == 0 { + return "" + } + var b strings.Builder + b.WriteString("Attached files:\n") + for _, attachment := range attachments { + name := strings.TrimSpace(attachment.Name) + if name == "" { + name = strings.TrimSpace(attachment.ID) + } + if name == "" { + name = "attachment" + } + b.WriteString("- ") + b.WriteString(name) + if mediaType := strings.TrimSpace(attachment.MediaType); mediaType != "" { + b.WriteString(" (") + b.WriteString(mediaType) + if attachment.SizeBytes > 0 { + b.WriteString(", ") + b.WriteString(formatAttachmentBytes(attachment.SizeBytes)) + } + b.WriteString(")") + } else if attachment.SizeBytes > 0 { + b.WriteString(" (") + b.WriteString(formatAttachmentBytes(attachment.SizeBytes)) + b.WriteString(")") + } + if path := strings.TrimSpace(attachment.WorkspacePath); path != "" { + b.WriteString(" workspace_path=") + b.WriteString(path) + } + if url := strings.TrimSpace(attachment.DownloadURL); url != "" { + b.WriteString(" download_url=") + b.WriteString(url) + } + b.WriteByte('\n') + } + return strings.TrimSpace(b.String()) +} + +func formatInlineAttachmentSummary(attachments []MessageAttachment) string { + if len(attachments) == 0 { + return "" + } + names := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + name := strings.TrimSpace(attachment.Name) + if name == "" { + name = strings.TrimSpace(attachment.ID) + } + if name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + return "[attachments]" + } + return "[attachments: " + strings.Join(names, ", ") + "]" +} + +func formatAttachmentBytes(size int64) string { + if size < 1024 { + return fmt.Sprintf("%d B", size) + } + if size < 1024*1024 { + return fmt.Sprintf("%.1f KiB", float64(size)/1024) + } + return fmt.Sprintf("%.1f MiB", float64(size)/(1024*1024)) +} + func eventDedupKey(evt BotEvent) string { messageID := strings.TrimSpace(evt.MessageID) if messageID == "" { diff --git a/internal/channelbridge/codexbridge/bridge_test.go b/internal/channelbridge/codexbridge/bridge_test.go index f05a75f1..507b85b0 100644 --- a/internal/channelbridge/codexbridge/bridge_test.go +++ b/internal/channelbridge/codexbridge/bridge_test.go @@ -400,6 +400,46 @@ func TestServiceInjectsChannelContextForFeishuEvents(t *testing.T) { }) } +func TestServiceInjectsChannelContextForLocalCSGClawEvents(t *testing.T) { + t.Parallel() + + stream := make(chan BotEvent, 1) + errs := make(chan error) + close(errs) + stream <- BotEvent{ + Channel: "csgclaw", + MessageID: "msg-local", + RoomID: "room-local", + ChatType: "direct", + Text: "find the file I sent earlier", + } + + sink := runtimecodex.NewEventSink() + client := &fakeBotClient{ + streams: map[string][]streamResult{ + "manager": {{events: stream, errs: errs}}, + }, + } + prompter := &fakePrompter{} + + svc := NewService(client, prompter, sink) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := svc.StartBot(ctx, Binding{BotID: "manager", RuntimeID: "rt-manager", SessionID: "sess-manager"}); err != nil { + t.Fatalf("StartBot() error = %v", err) + } + defer svc.Close() + + waitFor(t, func() bool { + texts := prompter.texts() + return len(texts) == 1 && + strings.Contains(texts[0], "channel: csgclaw") && + strings.Contains(texts[0], "room_id: room-local") && + strings.Contains(texts[0], "participant_id: manager") && + strings.Contains(texts[0], "Current message:\nfind the file I sent earlier") + }) +} + func TestServiceEnsuresConversationSessionAndInjectsHiddenThreadContext(t *testing.T) { t.Parallel() @@ -471,6 +511,53 @@ func TestServiceEnsuresConversationSessionAndInjectsHiddenThreadContext(t *testi } } +func TestServiceAddsAttachmentManifestToPrompt(t *testing.T) { + t.Parallel() + + stream := make(chan BotEvent, 1) + errs := make(chan error) + close(errs) + stream <- BotEvent{ + MessageID: "m-attach", + RoomID: "room-1", + Text: "please inspect", + Attachments: []MessageAttachment{{ + ID: "att-1", + Name: "diagram.png", + Kind: "image", + MediaType: "image/png", + SizeBytes: 42, + SHA256: "abc123", + DownloadURL: "/api/v1/attachments/att-1", + WorkspacePath: ".csgclaw/attachments/room-1/m-attach/att-1-diagram.png", + }}, + } + + sink := runtimecodex.NewEventSink() + client := &fakeBotClient{ + streams: map[string][]streamResult{ + "u-codex": {{events: stream, errs: errs}}, + }, + } + prompter := &fakePrompter{} + svc := NewService(client, prompter, sink) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := svc.StartBot(ctx, Binding{BotID: "u-codex", RuntimeID: "rt-1", SessionID: "sess-1"}); err != nil { + t.Fatalf("StartBot() error = %v", err) + } + defer svc.Close() + + waitFor(t, func() bool { + texts := prompter.texts() + return len(texts) == 1 && + strings.Contains(texts[0], "please inspect") && + strings.Contains(texts[0], "Attached files:") && + strings.Contains(texts[0], "diagram.png") && + strings.Contains(texts[0], ".csgclaw/attachments/room-1/m-attach/att-1-diagram.png") + }) +} + func TestServiceUsesConversationScopedSessionAndTopLevelFinalReply(t *testing.T) { t.Parallel() @@ -1953,6 +2040,64 @@ func TestHTTPClientSendMessageUsesParticipantRoute(t *testing.T) { } } +func TestHTTPClientSendMessageUsesMultipartForAttachments(t *testing.T) { + t.Parallel() + + client := &HTTPClient{ + BaseURL: "http://example.test", + Token: "secret", + HTTPClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if err := req.ParseMultipartForm(1024); err != nil { + t.Fatalf("ParseMultipartForm() error = %v", err) + } + var payload SendMessageRequest + if err := json.Unmarshal([]byte(req.FormValue("payload")), &payload); err != nil { + t.Fatalf("decode multipart payload: %v", err) + } + if payload.RoomID != "room-1" || payload.Text != "generated report" || payload.ThreadRootID != "msg-root" { + t.Fatalf("multipart payload = %+v", payload) + } + files := req.MultipartForm.File["files"] + if len(files) != 1 || files[0].Filename != "report.txt" { + t.Fatalf("multipart files = %+v, want report.txt", files) + } + file, err := files[0].Open() + if err != nil { + t.Fatalf("open multipart file: %v", err) + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil || string(data) != "report contents" { + t.Fatalf("multipart file = %q, err=%v", data, err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"message_id":"m-attach"}`)), + }, nil + }), + }, + } + + response, err := client.SendMessage(context.Background(), "u-codex", SendMessageRequest{ + RoomID: "room-1", + Text: "generated report", + ThreadRootID: "msg-root", + Attachments: []MessageAttachmentUpload{{ + Name: "report.txt", + MediaType: "text/plain", + Data: []byte("report contents"), + }}, + }) + if err != nil { + t.Fatalf("SendMessage() error = %v", err) + } + if response.MessageID != "m-attach" { + t.Fatalf("MessageID = %q, want m-attach", response.MessageID) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/channelbridge/codexbridge/sse_client.go b/internal/channelbridge/codexbridge/sse_client.go index b98ee810..3eccebc4 100644 --- a/internal/channelbridge/codexbridge/sse_client.go +++ b/internal/channelbridge/codexbridge/sse_client.go @@ -7,7 +7,10 @@ import ( "encoding/json" "fmt" "io" + "mime" + "mime/multipart" "net/http" + "net/textproto" "net/url" "strings" "time" @@ -16,6 +19,8 @@ import ( ) type BotEvent = channelbridge.BotEvent +type MessageAttachment = channelbridge.MessageAttachment +type MessageAttachmentUpload = channelbridge.MessageAttachmentUpload type BotThreadContext = channelbridge.BotThreadContext type BotThreadContextMessage = channelbridge.BotThreadContextMessage type BotThreadContextSummary = channelbridge.BotThreadContextSummary @@ -85,15 +90,15 @@ func (c *HTTPClient) StreamEvents(ctx context.Context, botID, lastEventID string } func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMessageRequest) (SendMessageResponse, error) { - payload, err := json.Marshal(req) + body, contentType, err := encodeSendMessageRequest(req) if err != nil { - return SendMessageResponse{}, fmt.Errorf("marshal send message request: %w", err) + return SendMessageResponse{}, err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.participantBridgeURL(botID, "/messages"), bytes.NewReader(payload)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.participantBridgeURL(botID, "/messages"), body) if err != nil { return SendMessageResponse{}, err } - httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Content-Type", contentType) if token := strings.TrimSpace(c.Token); token != "" { httpReq.Header.Set("Authorization", "Bearer "+token) } @@ -114,6 +119,59 @@ func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMess return sendResp, nil } +func encodeSendMessageRequest(req SendMessageRequest) (io.Reader, string, error) { + if len(req.Attachments) == 0 { + payload, err := json.Marshal(req) + if err != nil { + return nil, "", fmt.Errorf("marshal send message request: %w", err) + } + return bytes.NewReader(payload), "application/json", nil + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + payloadReq := req + payloadReq.Attachments = nil + payload, err := json.Marshal(payloadReq) + if err != nil { + return nil, "", fmt.Errorf("marshal send message request: %w", err) + } + payloadPart, err := writer.CreateFormField("payload") + if err != nil { + return nil, "", fmt.Errorf("create send message payload part: %w", err) + } + if _, err := payloadPart.Write(payload); err != nil { + return nil, "", fmt.Errorf("write send message payload part: %w", err) + } + for _, attachment := range req.Attachments { + name := strings.TrimSpace(attachment.Name) + if name == "" { + name = "attachment" + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{ + "name": "files", + "filename": name, + })) + mediaType := strings.TrimSpace(attachment.MediaType) + if mediaType == "" { + mediaType = "application/octet-stream" + } + header.Set("Content-Type", mediaType) + part, err := writer.CreatePart(header) + if err != nil { + return nil, "", fmt.Errorf("create send message attachment part: %w", err) + } + if _, err := part.Write(attachment.Data); err != nil { + return nil, "", fmt.Errorf("write send message attachment part: %w", err) + } + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("close send message multipart body: %w", err) + } + return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil +} + func (c *HTTPClient) participantBridgeURL(participantID, suffix string) string { baseURL := "" if c != nil { diff --git a/internal/channelbridge/types.go b/internal/channelbridge/types.go index 8cf17b5b..195bb4bd 100644 --- a/internal/channelbridge/types.go +++ b/internal/channelbridge/types.go @@ -1,17 +1,26 @@ package channelbridge -import "context" +import ( + "context" + + "csgclaw/internal/apitypes" +) + +type MessageAttachment = apitypes.MessageAttachment + +type MessageAttachmentUpload = apitypes.MessageAttachmentUpload type BotEvent struct { - Channel string `json:"channel,omitempty"` - ParticipantID string `json:"participant_id,omitempty"` - MessageID string `json:"message_id"` - RoomID string `json:"room_id"` - ChatType string `json:"chat_type"` - Text string `json:"text"` - Mentions []string `json:"mentions,omitempty"` - ThreadRootID string `json:"thread_root_id,omitempty"` - ThreadContext *BotThreadContext `json:"thread_context,omitempty"` + Channel string `json:"channel,omitempty"` + ParticipantID string `json:"participant_id,omitempty"` + MessageID string `json:"message_id"` + RoomID string `json:"room_id"` + ChatType string `json:"chat_type"` + Text string `json:"text"` + Attachments []MessageAttachment `json:"attachments,omitempty"` + Mentions []string `json:"mentions,omitempty"` + ThreadRootID string `json:"thread_root_id,omitempty"` + ThreadContext *BotThreadContext `json:"thread_context,omitempty"` } type BotThreadContext struct { @@ -21,10 +30,11 @@ type BotThreadContext struct { } type BotThreadContextMessage struct { - ID string `json:"id,omitempty"` - SenderID string `json:"sender_id,omitempty"` - Content string `json:"content,omitempty"` - CreatedAt string `json:"created_at,omitempty"` + ID string `json:"id,omitempty"` + SenderID string `json:"sender_id,omitempty"` + Content string `json:"content,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Attachments []MessageAttachment `json:"attachments,omitempty"` } type BotThreadContextSummary struct { @@ -35,10 +45,11 @@ type BotThreadContextSummary struct { } type SendMessageRequest struct { - RoomID string `json:"room_id"` - Text string `json:"text"` - ThreadRootID string `json:"thread_root_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + RoomID string `json:"room_id"` + Text string `json:"text"` + ThreadRootID string `json:"thread_root_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Attachments []MessageAttachmentUpload `json:"attachments,omitempty"` } type SendMessageResponse struct { diff --git a/internal/im/asset_store.go b/internal/im/asset_store.go new file mode 100644 index 00000000..5c7da0a2 --- /dev/null +++ b/internal/im/asset_store.go @@ -0,0 +1,689 @@ +package im + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + assetsDirName = "assets" + assetObjectsDirName = "objects" + assetBlobsDirName = "blobs" + attachmentKindFile = "file" + attachmentKindImage = "image" + MaxAttachmentsPerMessage = 10 + MaxAttachmentFileBytes = 25 * 1024 * 1024 + MaxAttachmentMessageBytes = 64 * 1024 * 1024 + maxSafeAttachmentNameBytes = 160 +) + +type attachmentObject struct { + ID string `json:"id"` + BlobSHA256 string `json:"blob_sha256"` + OriginalName string `json:"original_name"` + SafeName string `json:"safe_name"` + MediaType string `json:"media_type"` + Kind string `json:"kind"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` + CreatedBy string `json:"created_by"` + RoomID string `json:"room_id"` + MessageID string `json:"message_id"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + DownloadToken string `json:"download_token"` +} + +type AttachmentFile struct { + Attachment MessageAttachment + Path string + SafeName string + MediaType string + DownloadToken string +} + +func (s *Service) storeMessageAttachmentsLocked(roomID, messageID, senderID string, uploads []MessageAttachmentUpload) ([]MessageAttachment, error) { + if len(uploads) == 0 { + return nil, nil + } + if s == nil || strings.TrimSpace(s.statePath) == "" { + return nil, fmt.Errorf("attachment storage requires persistent IM state") + } + if len(uploads) > MaxAttachmentsPerMessage { + return nil, fmt.Errorf("too many attachments: max %d", MaxAttachmentsPerMessage) + } + total := int64(0) + for _, upload := range uploads { + data := upload.Data + if len(data) == 0 { + return nil, fmt.Errorf("attachment %q is empty", strings.TrimSpace(upload.Name)) + } + if len(data) > MaxAttachmentFileBytes { + return nil, fmt.Errorf("attachment %q exceeds %d bytes", strings.TrimSpace(upload.Name), MaxAttachmentFileBytes) + } + total += int64(len(data)) + if total > MaxAttachmentMessageBytes { + return nil, fmt.Errorf("attachments exceed %d bytes per message", MaxAttachmentMessageBytes) + } + if _, err := validatedAttachmentOriginalName(upload.Name); err != nil { + return nil, err + } + } + + attachments := make([]MessageAttachment, 0, len(uploads)) + for _, upload := range uploads { + data := upload.Data + att, err := s.storeAttachmentLocked(roomID, messageID, senderID, upload, data) + if err != nil { + state := s.bootstrapLocked() + if cleanupErr := cleanupAssetFilesForState(s.statePath, state.Rooms); cleanupErr != nil { + return nil, errors.Join(err, cleanupErr) + } + return nil, err + } + attachments = append(attachments, att) + } + return attachments, nil +} + +func (s *Service) storeAttachmentLocked(roomID, messageID, senderID string, upload MessageAttachmentUpload, data []byte) (MessageAttachment, error) { + sum := sha256.Sum256(data) + sha := hex.EncodeToString(sum[:]) + mediaType := normalizedAttachmentMediaType(upload.MediaType, data) + kind := attachmentKindForMediaType(mediaType) + width, height := imageDimensions(data) + id, err := newAttachmentID(sha) + if err != nil { + return MessageAttachment{}, err + } + downloadToken, err := newAttachmentDownloadToken() + if err != nil { + return MessageAttachment{}, err + } + originalName, err := validatedAttachmentOriginalName(upload.Name) + if err != nil { + return MessageAttachment{}, err + } + safeName := safeAttachmentName(originalName) + createdAt := time.Now().UTC() + object := attachmentObject{ + ID: id, + BlobSHA256: sha, + OriginalName: originalName, + SafeName: safeName, + MediaType: mediaType, + Kind: kind, + SizeBytes: int64(len(data)), + SHA256: sha, + CreatedAt: createdAt, + CreatedBy: strings.TrimSpace(senderID), + RoomID: strings.TrimSpace(roomID), + MessageID: strings.TrimSpace(messageID), + Width: width, + Height: height, + DownloadToken: downloadToken, + } + if err := writeAttachmentBlob(attachmentBlobPath(s.statePath, sha), sha, data); err != nil { + return MessageAttachment{}, err + } + if err := writeAttachmentObject(attachmentObjectPath(s.statePath, id), object); err != nil { + return MessageAttachment{}, err + } + return attachmentFromObject(object), nil +} + +func (s *Service) AttachmentFile(id string) (AttachmentFile, error) { + id = strings.TrimSpace(id) + if !validAttachmentID(id) { + return AttachmentFile{}, fmt.Errorf("attachment id is required") + } + if s == nil || strings.TrimSpace(s.statePath) == "" { + return AttachmentFile{}, fmt.Errorf("attachment storage is not configured") + } + object, err := readAttachmentObject(attachmentObjectPath(s.statePath, id)) + if err != nil { + return AttachmentFile{}, err + } + if object.ID != id { + return AttachmentFile{}, fmt.Errorf("attachment not found") + } + path := attachmentBlobPath(s.statePath, object.BlobSHA256) + if _, err := os.Stat(path); err != nil { + return AttachmentFile{}, fmt.Errorf("stat attachment blob: %w", err) + } + return AttachmentFile{ + Attachment: attachmentFromObject(object), + Path: path, + SafeName: object.SafeName, + MediaType: object.MediaType, + DownloadToken: object.DownloadToken, + }, nil +} + +func (f AttachmentFile) AuthorizesDownloadToken(token string) bool { + want := strings.TrimSpace(f.DownloadToken) + got := strings.TrimSpace(token) + if want == "" || len(got) != len(want) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +func (s *Service) MaterializeAttachment(id, workspaceRoot, relativeDir string) (MessageAttachment, error) { + file, err := s.AttachmentFile(id) + if err != nil { + return MessageAttachment{}, err + } + workspaceRoot = strings.TrimSpace(workspaceRoot) + if workspaceRoot == "" { + return MessageAttachment{}, fmt.Errorf("workspace root is required") + } + relativeDir = strings.Trim(strings.TrimSpace(filepath.ToSlash(relativeDir)), "/") + if relativeDir == "" { + relativeDir = ".csgclaw/attachments" + } + relativeName := filepath.ToSlash(filepath.Join(relativeDir, file.Attachment.ID+"-"+file.SafeName)) + targetPath, err := prepareWorkspaceAttachmentPath(workspaceRoot, relativeName) + if err != nil { + return MessageAttachment{}, err + } + data, err := os.ReadFile(file.Path) + if err != nil { + return MessageAttachment{}, fmt.Errorf("read attachment blob: %w", err) + } + if err := atomicWriteFile(targetPath, data, 0o600); err != nil { + return MessageAttachment{}, fmt.Errorf("write attachment workspace file: %w", err) + } + att := file.Attachment + att.WorkspacePath = relativeName + return att, nil +} + +func attachmentFromObject(object attachmentObject) MessageAttachment { + att := MessageAttachment{ + ID: object.ID, + Name: object.SafeName, + Kind: object.Kind, + MediaType: object.MediaType, + SizeBytes: object.SizeBytes, + SHA256: object.SHA256, + CreatedAt: object.CreatedAt, + DownloadURL: attachmentDownloadURL(object), + Width: object.Width, + Height: object.Height, + } + if att.Kind == attachmentKindImage { + att.PreviewURL = att.DownloadURL + } + return att +} + +func attachmentDownloadURL(object attachmentObject) string { + downloadURL := "/api/v1/attachments/" + url.PathEscape(object.ID) + if token := strings.TrimSpace(object.DownloadToken); token != "" { + downloadURL += "?token=" + url.QueryEscape(token) + } + return downloadURL +} + +func normalizedAttachmentMediaType(declared string, data []byte) string { + detected := "application/octet-stream" + if len(data) > 0 { + detected = strings.ToLower(strings.TrimSpace(strings.SplitN(http.DetectContentType(data), ";", 2)[0])) + } + declared = strings.TrimSpace(declared) + if declared != "" { + if mediaType, _, err := mime.ParseMediaType(declared); err == nil && strings.TrimSpace(mediaType) != "" { + declared = strings.ToLower(strings.TrimSpace(mediaType)) + if strings.HasPrefix(declared, "image/") { + if strings.HasPrefix(detected, "image/") { + return detected + } + if declared == "image/svg+xml" && looksLikeSVG(data) { + return declared + } + return detected + } + return declared + } + } + return detected +} + +func looksLikeSVG(data []byte) bool { + if len(data) == 0 { + return false + } + prefix := bytes.ToLower(bytes.TrimSpace(data)) + if len(prefix) > 1024 { + prefix = prefix[:1024] + } + return bytes.Contains(prefix, []byte(":"|?*`, r) { + b.WriteByte('_') + continue + } + b.WriteRune(r) + } + name = strings.TrimSpace(b.String()) + name = strings.Trim(name, " .") + if name == "" { + return "attachment" + } + return truncateAttachmentName(name) +} + +func validatedAttachmentOriginalName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "attachment", nil + } + if name == "." || name == ".." || strings.ContainsAny(name, `/\\`) { + return "", fmt.Errorf("attachment filename %q is unsafe", name) + } + for _, r := range name { + if r == 0 || unicode.IsControl(r) { + return "", fmt.Errorf("attachment filename %q is unsafe", name) + } + } + if len([]byte(name)) > 255 { + return "", fmt.Errorf("attachment filename is too long") + } + return name, nil +} + +func truncateAttachmentName(name string) string { + if len([]byte(name)) <= maxSafeAttachmentNameBytes { + return name + } + ext := filepath.Ext(name) + if len([]byte(ext)) > 20 { + ext = "" + } + base := strings.TrimSuffix(name, ext) + base = strings.TrimRight(truncateUTF8Bytes(base, maxSafeAttachmentNameBytes-len([]byte(ext))), " .") + if base == "" { + base = "attachment" + } + return base + ext +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if maxBytes <= 0 { + return "" + } + var b strings.Builder + for _, r := range value { + encoded := string(r) + if b.Len()+len(encoded) > maxBytes { + break + } + b.WriteString(encoded) + } + return b.String() +} + +func newAttachmentID(sha string) (string, error) { + var randomBytes [16]byte + if _, err := rand.Read(randomBytes[:]); err != nil { + return "", fmt.Errorf("generate attachment id: %w", err) + } + shortSHA := sha + if len(shortSHA) > 12 { + shortSHA = shortSHA[:12] + } + return "att-" + shortSHA + "-" + hex.EncodeToString(randomBytes[:]), nil +} + +func newAttachmentDownloadToken() (string, error) { + var randomBytes [24]byte + if _, err := rand.Read(randomBytes[:]); err != nil { + return "", fmt.Errorf("generate attachment download token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(randomBytes[:]), nil +} + +func validAttachmentID(id string) bool { + if !strings.HasPrefix(id, "att-") || len(id) < 8 || len(id) > 96 { + return false + } + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return false + } + return true +} + +func validSHA256(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func attachmentObjectPath(statePath, id string) string { + return filepath.Join(filepath.Dir(statePath), assetsDirName, assetObjectsDirName, id+".json") +} + +func attachmentBlobPath(statePath, sha string) string { + prefix := "00" + if len(sha) >= 2 { + prefix = sha[:2] + } + return filepath.Join(filepath.Dir(statePath), assetsDirName, assetBlobsDirName, "sha256", prefix, sha) +} + +func writeAttachmentBlob(path, expectedSHA string, data []byte) error { + if _, err := os.Stat(path); err == nil { + matches, verifyErr := attachmentBlobMatches(path, expectedSHA, int64(len(data))) + if verifyErr != nil { + return verifyErr + } + if matches { + return nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat attachment blob: %w", err) + } + return atomicWriteFile(path, data, 0o600) +} + +func attachmentBlobMatches(path, expectedSHA string, expectedSize int64) (bool, error) { + file, err := os.Open(path) + if err != nil { + return false, fmt.Errorf("open attachment blob: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return false, fmt.Errorf("stat attachment blob: %w", err) + } + if info.Size() != expectedSize { + return false, nil + } + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return false, fmt.Errorf("hash attachment blob: %w", err) + } + return hex.EncodeToString(hash.Sum(nil)) == expectedSHA, nil +} + +func writeAttachmentObject(path string, object attachmentObject) error { + data, err := json.MarshalIndent(object, "", " ") + if err != nil { + return fmt.Errorf("encode attachment object: %w", err) + } + data = append(data, '\n') + return atomicWriteFile(path, data, 0o600) +} + +func readAttachmentObject(path string) (attachmentObject, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return attachmentObject{}, fmt.Errorf("attachment not found") + } + return attachmentObject{}, fmt.Errorf("read attachment object: %w", err) + } + var object attachmentObject + if err := json.Unmarshal(data, &object); err != nil { + return attachmentObject{}, fmt.Errorf("decode attachment object: %w", err) + } + if !validAttachmentID(object.ID) || !validSHA256(object.BlobSHA256) || !validSHA256(object.SHA256) || object.SizeBytes < 0 || strings.TrimSpace(object.SafeName) == "" { + return attachmentObject{}, fmt.Errorf("decode attachment object: invalid metadata") + } + return object, nil +} + +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create parent dir: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpName) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + cleanup = false + return nil +} + +func prepareWorkspaceAttachmentPath(root, relative string) (string, error) { + root = strings.TrimSpace(root) + relative = strings.TrimSpace(relative) + if root == "" || relative == "" || filepath.IsAbs(relative) { + return "", fmt.Errorf("unsafe attachment path") + } + cleanRelative := filepath.Clean(filepath.FromSlash(relative)) + if cleanRelative == "." || cleanRelative == ".." || strings.HasPrefix(cleanRelative, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("unsafe attachment path") + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolve attachment workspace root: %w", err) + } + resolvedRoot, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return "", fmt.Errorf("resolve attachment workspace root: %w", err) + } + parts := strings.Split(cleanRelative, string(os.PathSeparator)) + if len(parts) == 0 || parts[len(parts)-1] == "" { + return "", fmt.Errorf("unsafe attachment path") + } + current := resolvedRoot + for _, part := range parts[:len(parts)-1] { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("unsafe attachment path") + } + next := filepath.Join(current, part) + info, err := os.Lstat(next) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(next, 0o700); err == nil { + current = next + continue + } else if !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("create attachment workspace dir: %w", err) + } + info, err = os.Lstat(next) + } + if err != nil { + return "", fmt.Errorf("inspect attachment workspace dir: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(next) + if err != nil { + return "", fmt.Errorf("resolve attachment workspace dir: %w", err) + } + if !pathWithinRoot(resolvedRoot, resolved) { + return "", fmt.Errorf("unsafe attachment path") + } + current = resolved + continue + } + if !info.IsDir() { + return "", fmt.Errorf("attachment workspace path is not a directory") + } + current = next + } + return filepath.Join(current, parts[len(parts)-1]), nil +} + +func pathWithinRoot(root, path string) bool { + relative, err := filepath.Rel(root, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) +} + +func cleanupAssetFilesForState(statePath string, rooms []Room) error { + statePath = strings.TrimSpace(statePath) + if statePath == "" { + return nil + } + objectsDir := filepath.Join(filepath.Dir(statePath), assetsDirName, assetObjectsDirName) + entries, err := os.ReadDir(objectsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read attachment objects dir: %w", err) + } + + live, liveSHA := liveAttachmentRefs(rooms) + canSweepBlobs := true + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + path := filepath.Join(objectsDir, entry.Name()) + if _, ok := live[id]; !ok { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale attachment object: %w", err) + } + continue + } + object, err := readAttachmentObject(path) + if err != nil { + canSweepBlobs = false + continue + } + if strings.TrimSpace(object.BlobSHA256) != "" { + liveSHA[object.BlobSHA256] = struct{}{} + } + } + if !canSweepBlobs { + return nil + } + return cleanupUnreferencedAttachmentBlobs(filepath.Join(filepath.Dir(statePath), assetsDirName, assetBlobsDirName, "sha256"), liveSHA) +} + +func liveAttachmentRefs(rooms []Room) (map[string]struct{}, map[string]struct{}) { + live := make(map[string]struct{}) + liveSHA := make(map[string]struct{}) + for _, room := range rooms { + recordLiveAttachments(live, liveSHA, room.Messages) + for _, thread := range room.Threads { + recordLiveAttachments(live, liveSHA, thread.Context) + } + } + return live, liveSHA +} + +func recordLiveAttachments(live, liveSHA map[string]struct{}, messages []Message) { + for _, message := range messages { + for _, attachment := range message.Attachments { + if id := strings.TrimSpace(attachment.ID); id != "" { + live[id] = struct{}{} + } + if sha := strings.TrimSpace(attachment.SHA256); validSHA256(sha) { + liveSHA[sha] = struct{}{} + } + } + } +} + +func cleanupUnreferencedAttachmentBlobs(root string, liveSHA map[string]struct{}) error { + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read attachment blobs dir: %w", err) + } + for _, prefixEntry := range entries { + if !prefixEntry.IsDir() { + continue + } + prefixDir := filepath.Join(root, prefixEntry.Name()) + blobEntries, err := os.ReadDir(prefixDir) + if err != nil { + return fmt.Errorf("read attachment blob prefix dir: %w", err) + } + for _, blobEntry := range blobEntries { + if blobEntry.IsDir() { + continue + } + if _, ok := liveSHA[blobEntry.Name()]; ok { + continue + } + if err := os.Remove(filepath.Join(prefixDir, blobEntry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale attachment blob: %w", err) + } + } + } + return nil +} diff --git a/internal/im/participant_bridge.go b/internal/im/participant_bridge.go index 85eaed52..dbd170b3 100644 --- a/internal/im/participant_bridge.go +++ b/internal/im/participant_bridge.go @@ -26,6 +26,7 @@ type ParticipantEvent struct { Sender ParticipantSender `json:"sender"` SenderID string `json:"sender_id,omitempty"` Text string `json:"text"` + Attachments []MessageAttachment `json:"attachments,omitempty"` Timestamp string `json:"timestamp"` Mentions []string `json:"mentions,omitempty"` ThreadRootID string `json:"thread_root_id,omitempty"` @@ -73,6 +74,7 @@ type ParticipantSendMessageRequest struct { TopicID string `json:"topic_id,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` Context *ParticipantMessageContext `json:"context,omitempty"` + Attachments []MessageAttachmentUpload `json:"attachments,omitempty"` } func (r ParticipantSendMessageRequest) ResolvedRoomID() string { @@ -371,6 +373,7 @@ func messageEventForParticipant(room Room, sender User, message Message, partici }, SenderID: sender.ID, Text: text, + Attachments: cloneMessageAttachments(message.Attachments), Timestamp: fmt.Sprintf("%d", message.CreatedAt.UnixMilli()), Mentions: mentions, ThreadContext: participantThreadContext(room, threadRootID), diff --git a/internal/im/participant_bridge_test.go b/internal/im/participant_bridge_test.go index 70742488..49ae5297 100644 --- a/internal/im/participant_bridge_test.go +++ b/internal/im/participant_bridge_test.go @@ -186,6 +186,77 @@ func TestPublishMessageEventIncludesThreadRootAndContext(t *testing.T) { } } +func TestPublishMessageEventIncludesAttachments(t *testing.T) { + bridge := NewParticipantBridge("") + events, cancel := bridge.Subscribe("u-bot") + defer cancel() + + attachment := MessageAttachment{ + ID: "att-1", + Name: "diagram.png", + Kind: "image", + MediaType: "image/png", + SizeBytes: 42, + SHA256: "abc123", + DownloadURL: "/api/v1/attachments/att-1", + WorkspacePath: ".csgclaw/attachments/room-group/msg-1/att-1-diagram.png", + } + root := Message{ + ID: "msg-root", + SenderID: "u-admin", + Content: "root context", + CreatedAt: time.Now().UTC(), + Attachments: []MessageAttachment{attachment}, + } + reply := Message{ + ID: "msg-1", + SenderID: "u-admin", + Content: "see attached", + CreatedAt: time.Now().UTC().Add(time.Second), + Attachments: []MessageAttachment{{ + ID: "att-2", + Name: "notes.txt", + Kind: "file", + MediaType: "text/plain", + SizeBytes: 12, + SHA256: "def456", + DownloadURL: "/api/v1/attachments/att-2", + }}, + RelatesTo: &MessageRelation{ + RelType: RelationTypeThread, + EventID: root.ID, + }, + } + room := Room{ + ID: "room-group", + IsDirect: false, + Members: []string{"u-admin", "u-bot"}, + Messages: []Message{root, reply}, + Threads: []ThreadState{{ + RootMessageID: root.ID, + Context: []Message{root}, + }}, + } + sender := User{ID: "u-admin", Name: "Admin"} + + bridge.PublishMessageEvent(room, sender, reply) + + select { + case evt := <-events: + if len(evt.Attachments) != 1 || evt.Attachments[0].ID != "att-2" { + t.Fatalf("Attachments = %+v, want reply attachment", evt.Attachments) + } + if evt.ThreadContext == nil || len(evt.ThreadContext.Context) != 1 { + t.Fatalf("ThreadContext = %+v, want root context", evt.ThreadContext) + } + if len(evt.ThreadContext.Context[0].Attachments) != 1 || evt.ThreadContext.Context[0].Attachments[0].ID != "att-1" { + t.Fatalf("ThreadContext.Context attachments = %+v, want root attachment", evt.ThreadContext.Context[0].Attachments) + } + case <-time.After(time.Second): + t.Fatal("PublishMessageEvent() timed out waiting for event") + } +} + func TestPublishMessageEventNormalizesPlainThreadMentionForPicoClaw(t *testing.T) { bridge := NewParticipantBridge("") events, cancel := bridge.Subscribe("u-qa") diff --git a/internal/im/service.go b/internal/im/service.go index 99a3281b..df6210be 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -22,6 +22,10 @@ type User = apitypes.User type Message = apitypes.Message +type MessageAttachment = apitypes.MessageAttachment + +type MessageAttachmentUpload = apitypes.MessageAttachmentUpload + type Mention = apitypes.Mention type EventPayload = apitypes.EventPayload @@ -78,13 +82,14 @@ type ThreadListOptions struct { } type DeliverMessageRequest struct { - RoomID string `json:"room_id"` - SenderID string `json:"sender_id,omitempty"` - MentionID string `json:"mention_id,omitempty"` - Content string `json:"text"` - MessageID string `json:"message_id,omitempty"` - ThreadRootID string `json:"thread_root_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + RoomID string `json:"room_id"` + SenderID string `json:"sender_id,omitempty"` + MentionID string `json:"mention_id,omitempty"` + Content string `json:"text"` + MessageID string `json:"message_id,omitempty"` + ThreadRootID string `json:"thread_root_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Attachments []MessageAttachmentUpload `json:"attachments,omitempty"` } type DeliverEventRequest struct { @@ -353,6 +358,20 @@ func ensureBootstrapDirs(path string) error { if err := os.MkdirAll(threadsDir, 0o755); err != nil { return fmt.Errorf("create im threads dir: %w", err) } + assetsDir := filepath.Join(filepath.Dir(path), assetsDirName) + objectsDir := filepath.Join(assetsDir, assetObjectsDirName) + if err := os.MkdirAll(objectsDir, 0o700); err != nil { + return fmt.Errorf("create im attachment objects dir: %w", err) + } + blobsDir := filepath.Join(assetsDir, assetBlobsDirName, "sha256") + if err := os.MkdirAll(blobsDir, 0o700); err != nil { + return fmt.Errorf("create im attachment blobs dir: %w", err) + } + for _, privateDir := range []string{assetsDir, objectsDir, filepath.Join(assetsDir, assetBlobsDirName), blobsDir} { + if err := os.Chmod(privateDir, 0o700); err != nil { + return fmt.Errorf("set private im attachment directory permissions: %w", err) + } + } return nil } @@ -493,6 +512,9 @@ func savePersistedBootstrap(statePath string, state Bootstrap) (persistedBootstr if err := cleanupThreadFiles(statePath, rooms); err != nil { return persistedBootstrap{}, err } + if err := cleanupAssetFilesForState(statePath, state.Rooms); err != nil { + return persistedBootstrap{}, err + } return persisted, nil } @@ -1726,7 +1748,7 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { if senderID == "" { return Message{}, fmt.Errorf("sender_id is required") } - if content == "" { + if content == "" && len(req.Attachments) == 0 { return Message{}, fmt.Errorf("content is required") } @@ -1758,6 +1780,11 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { message := s.newMessage("", senderID, MessageKindMessage, content) message.Metadata = utils.CloneAnyMap(req.Metadata) message.RelatesTo = relatesTo + attachments, err := s.storeMessageAttachmentsLocked(roomID, message.ID, senderID, req.Attachments) + if err != nil { + return Message{}, err + } + message.Attachments = attachments room.Messages = append(room.Messages, message) if err := s.saveLocked(); err != nil { return Message{}, err @@ -1773,7 +1800,7 @@ func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { if roomID == "" { return Message{}, fmt.Errorf("room_id is required") } - if content == "" { + if content == "" && len(req.Attachments) == 0 { return Message{}, fmt.Errorf("text is required") } if senderID == "" { @@ -1811,6 +1838,7 @@ func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { message := s.newMessage(req.MessageID, senderID, MessageKindMessage, content) message.Metadata = utils.CloneAnyMap(req.Metadata) message.RelatesTo = relatesTo + replaceIndex := -1 if strings.TrimSpace(req.MessageID) != "" { for idx := range room.Messages { if room.Messages[idx].ID != message.ID { @@ -1819,17 +1847,26 @@ func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { if room.Messages[idx].SenderID != senderID { return Message{}, fmt.Errorf("message id %q already exists for another sender", message.ID) } - message.CreatedAt = room.Messages[idx].CreatedAt - room.Messages[idx] = message - s.rebuildThreadStatesLocked(room) - if err := s.saveLocked(); err != nil { - return Message{}, err - } - presented := s.presentMessageLocked(*room, message, "") - s.publishMessageCreatedLocked(roomID, senderID, presented) - return presented, nil + replaceIndex = idx + break } } + attachments, err := s.storeMessageAttachmentsLocked(roomID, message.ID, senderID, req.Attachments) + if err != nil { + return Message{}, err + } + message.Attachments = attachments + if replaceIndex >= 0 { + message.CreatedAt = room.Messages[replaceIndex].CreatedAt + room.Messages[replaceIndex] = message + s.rebuildThreadStatesLocked(room) + if err := s.saveLocked(); err != nil { + return Message{}, err + } + presented := s.presentMessageLocked(*room, message, "") + s.publishMessageCreatedLocked(roomID, senderID, presented) + return presented, nil + } room.Messages = append(room.Messages, message) if err := s.saveLocked(); err != nil { return Message{}, err @@ -2460,6 +2497,7 @@ func cloneMessages(messages []Message) []Message { func cloneMessage(message Message) Message { cloned := message cloned.Mentions = append([]Mention(nil), message.Mentions...) + cloned.Attachments = cloneMessageAttachments(message.Attachments) cloned.Event = cloneEventPayload(message.Event) if message.RelatesTo != nil { rel := *message.RelatesTo @@ -2472,6 +2510,13 @@ func cloneMessage(message Message) Message { return cloned } +func cloneMessageAttachments(attachments []MessageAttachment) []MessageAttachment { + if len(attachments) == 0 { + return nil + } + return append([]MessageAttachment(nil), attachments...) +} + func cloneEventPayload(event *EventPayload) *EventPayload { if event == nil { return nil @@ -3000,7 +3045,11 @@ func (s *Service) saveRoomLocked(room Room) error { if err := saveRoomMessagesForState(s.statePath, room); err != nil { return err } - return writePersistedBootstrap(s.statePath, persistedBootstrapFromState(s.bootstrapLocked())) + state := s.bootstrapLocked() + if err := cleanupAssetFilesForState(s.statePath, state.Rooms); err != nil { + return err + } + return writePersistedBootstrap(s.statePath, persistedBootstrapFromState(state)) } func (s *Service) replaceState(state Bootstrap) { diff --git a/internal/im/service_test.go b/internal/im/service_test.go index f7ceaab9..284bc141 100644 --- a/internal/im/service_test.go +++ b/internal/im/service_test.go @@ -2,9 +2,13 @@ package im import ( "bufio" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -104,6 +108,208 @@ func TestCreateMessagePersistsUserIDsAndMentionNames(t *testing.T) { } } +func TestCreateMessageWithAttachmentStoresObjectAndBlob(t *testing.T) { + path := filepath.Join(t.TempDir(), "im", "state.json") + svc, err := NewServiceFromPath(path) + if err != nil { + t.Fatalf("NewServiceFromPath() error = %v", err) + } + if _, _, err := svc.EnsureAgentUser(EnsureAgentUserRequest{ID: "agent-worker", Name: "worker", Role: "worker"}); err != nil { + t.Fatalf("EnsureAgentUser(worker) error = %v", err) + } + room, err := svc.CreateRoom(CreateRoomRequest{ + Title: "Ops", + CreatorID: "user-admin", + MemberIDs: []string{"user-worker"}, + }) + if err != nil { + t.Fatalf("CreateRoom() error = %v", err) + } + + payload, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + if err != nil { + t.Fatalf("decode PNG fixture: %v", err) + } + sum := sha256.Sum256(payload) + wantSHA := hex.EncodeToString(sum[:]) + msg, err := svc.CreateMessage(CreateMessageRequest{ + RoomID: room.ID, + SenderID: "user-admin", + Attachments: []MessageAttachmentUpload{{ + Name: "diagram.png", + MediaType: "image/png", + Data: payload, + }}, + }) + if err != nil { + t.Fatalf("CreateMessage() error = %v", err) + } + if msg.Content != "" { + t.Fatalf("message content = %q, want attachment-only message", msg.Content) + } + if len(msg.Attachments) != 1 { + t.Fatalf("attachments = %+v, want one attachment", msg.Attachments) + } + att := msg.Attachments[0] + if att.Name != "diagram.png" || att.Kind != "image" || att.MediaType != "image/png" { + t.Fatalf("attachment = %+v, want sanitized image metadata", att) + } + if att.SizeBytes != int64(len(payload)) || att.SHA256 != wantSHA { + t.Fatalf("attachment size/sha = %d/%s, want %d/%s", att.SizeBytes, att.SHA256, len(payload), wantSHA) + } + if att.Width != 1 || att.Height != 1 { + t.Fatalf("attachment dimensions = %dx%d, want 1x1", att.Width, att.Height) + } + if att.DownloadURL == "" || !strings.HasPrefix(att.DownloadURL, "/api/v1/attachments/") { + t.Fatalf("download_url = %q, want API attachment URL", att.DownloadURL) + } + if !strings.Contains(att.DownloadURL, "?token=") { + t.Fatalf("download_url = %q, want attachment capability token", att.DownloadURL) + } + + objectPath := filepath.Join(filepath.Dir(path), "assets", "objects", att.ID+".json") + var object map[string]any + readJSONFileForTest(t, objectPath, &object) + if stringField(object, "room_id") != room.ID || stringField(object, "message_id") != msg.ID { + t.Fatalf("object room/message = %+v, want %s/%s", object, room.ID, msg.ID) + } + blobPath := filepath.Join(filepath.Dir(path), "assets", "blobs", "sha256", wantSHA[:2], wantSHA) + if got, err := os.ReadFile(blobPath); err != nil || string(got) != string(payload) { + t.Fatalf("blob data = %q, err=%v, want original payload", string(got), err) + } + if runtime.GOOS != "windows" { + for _, privateFile := range []string{objectPath, blobPath} { + info, err := os.Stat(privateFile) + if err != nil { + t.Fatalf("stat %s: %v", privateFile, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("permissions for %s = %o, want 600", privateFile, got) + } + } + assetsInfo, err := os.Stat(filepath.Join(filepath.Dir(path), "assets")) + if err != nil { + t.Fatalf("stat attachment assets dir: %v", err) + } + if got := assetsInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("attachment assets dir permissions = %o, want 700", got) + } + } + + workspaceRoot := t.TempDir() + materialized, err := svc.MaterializeAttachment( + att.ID, + workspaceRoot, + filepath.ToSlash(filepath.Join(".csgclaw", "attachments", room.ID, msg.ID)), + ) + if err != nil { + t.Fatalf("MaterializeAttachment() error = %v", err) + } + if materialized.WorkspacePath == "" { + t.Fatal("MaterializeAttachment() workspace_path is empty") + } + materializedPath := filepath.Join(workspaceRoot, filepath.FromSlash(materialized.WorkspacePath)) + if got, err := os.ReadFile(materializedPath); err != nil || string(got) != string(payload) { + t.Fatalf("materialized attachment = %q, err=%v, want original payload", string(got), err) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(materializedPath) + if err != nil { + t.Fatalf("stat materialized attachment: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("materialized attachment permissions = %o, want 600", got) + } + } + + reloaded, err := NewServiceFromPath(path) + if err != nil { + t.Fatalf("NewServiceFromPath(reload) error = %v", err) + } + messages, err := reloaded.ListMessages(room.ID) + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + var reloadedMsg Message + for _, candidate := range messages { + if candidate.ID == msg.ID { + reloadedMsg = candidate + break + } + } + if reloadedMsg.ID == "" || len(reloadedMsg.Attachments) != 1 || reloadedMsg.Attachments[0].ID != att.ID { + t.Fatalf("reloaded messages = %+v, want persisted attachment", messages) + } +} + +func TestCreateMessageRejectsUnsafeAttachmentName(t *testing.T) { + path := filepath.Join(t.TempDir(), "im", "state.json") + svc, err := NewServiceFromPath(path) + if err != nil { + t.Fatalf("NewServiceFromPath() error = %v", err) + } + room, err := svc.CreateRoom(CreateRoomRequest{Title: "Uploads", CreatorID: "user-admin"}) + if err != nil { + t.Fatalf("CreateRoom() error = %v", err) + } + + _, err = svc.CreateMessage(CreateMessageRequest{ + RoomID: room.ID, + SenderID: "user-admin", + Attachments: []MessageAttachmentUpload{{ + Name: "../secret.txt", + MediaType: "text/plain", + Data: []byte("secret"), + }}, + }) + if err == nil || !strings.Contains(err.Error(), "filename") || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("CreateMessage() error = %v, want unsafe filename rejection", err) + } + entries, err := os.ReadDir(filepath.Join(filepath.Dir(path), "assets", "objects")) + if err != nil { + t.Fatalf("read attachment objects: %v", err) + } + if len(entries) != 0 { + t.Fatalf("attachment objects = %d, want none after rejected upload", len(entries)) + } +} + +func TestClearRoomMessagesCleansAttachmentObjectsAndBlobs(t *testing.T) { + path := filepath.Join(t.TempDir(), "im", "state.json") + svc, err := NewServiceFromPath(path) + if err != nil { + t.Fatalf("NewServiceFromPath() error = %v", err) + } + room, err := svc.CreateRoom(CreateRoomRequest{Title: "Uploads", CreatorID: "user-admin"}) + if err != nil { + t.Fatalf("CreateRoom() error = %v", err) + } + message, err := svc.CreateMessage(CreateMessageRequest{ + RoomID: room.ID, + SenderID: "user-admin", + Attachments: []MessageAttachmentUpload{{ + Name: "note.txt", + MediaType: "text/plain", + Data: []byte("cleanup me"), + }}, + }) + if err != nil { + t.Fatalf("CreateMessage() error = %v", err) + } + attachment := message.Attachments[0] + objectPath := filepath.Join(filepath.Dir(path), "assets", "objects", attachment.ID+".json") + blobPath := filepath.Join(filepath.Dir(path), "assets", "blobs", "sha256", attachment.SHA256[:2], attachment.SHA256) + + if _, err := svc.ClearRoomMessages(room.ID); err != nil { + t.Fatalf("ClearRoomMessages() error = %v", err) + } + for _, removedPath := range []string{objectPath, blobPath} { + if _, err := os.Stat(removedPath); !os.IsNotExist(err) { + t.Fatalf("os.Stat(%q) error = %v, want removed attachment asset", removedPath, err) + } + } +} + func readJSONFileForTest(t *testing.T, path string, out any) { t.Helper() data, err := os.ReadFile(path) diff --git a/internal/im/session_store.go b/internal/im/session_store.go index f19ef230..1265fb17 100644 --- a/internal/im/session_store.go +++ b/internal/im/session_store.go @@ -23,37 +23,40 @@ const ( // sessionMessageLine is the on-disk jsonl shape. blob_ref points at spillover payload. type sessionMessageLine struct { - ID string `json:"id"` - SenderID string `json:"sender_id"` - Kind string `json:"kind,omitempty"` - Content string `json:"content"` - Event *EventPayload `json:"event,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt string `json:"created_at"` - Mentions []Mention `json:"mentions"` - RelatesTo *MessageRelation `json:"relates_to,omitempty"` - Thread *ThreadSummary `json:"thread,omitempty"` - BlobRef string `json:"blob_ref,omitempty"` + ID string `json:"id"` + SenderID string `json:"sender_id"` + Kind string `json:"kind,omitempty"` + Content string `json:"content"` + Event *EventPayload `json:"event,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + Mentions []Mention `json:"mentions"` + RelatesTo *MessageRelation `json:"relates_to,omitempty"` + Thread *ThreadSummary `json:"thread,omitempty"` + Attachments []MessageAttachment `json:"attachments,omitempty"` + BlobRef string `json:"blob_ref,omitempty"` } type sessionMessageBlob struct { - Content string `json:"content,omitempty"` - Event *EventPayload `json:"event,omitempty"` - Thread *ThreadSummary `json:"thread,omitempty"` + Content string `json:"content,omitempty"` + Event *EventPayload `json:"event,omitempty"` + Thread *ThreadSummary `json:"thread,omitempty"` + Attachments []MessageAttachment `json:"attachments,omitempty"` } func messageToSessionLine(message Message) sessionMessageLine { return sessionMessageLine{ - ID: message.ID, - SenderID: message.SenderID, - Kind: message.Kind, - Content: message.Content, - Event: message.Event, - Metadata: message.Metadata, - CreatedAt: message.CreatedAt.UTC().Format(timeRFC3339Nano), - Mentions: message.Mentions, - RelatesTo: message.RelatesTo, - Thread: message.Thread, + ID: message.ID, + SenderID: message.SenderID, + Kind: message.Kind, + Content: message.Content, + Event: message.Event, + Metadata: message.Metadata, + CreatedAt: message.CreatedAt.UTC().Format(timeRFC3339Nano), + Mentions: message.Mentions, + RelatesTo: message.RelatesTo, + Thread: message.Thread, + Attachments: message.Attachments, } } @@ -63,16 +66,17 @@ func sessionLineToMessage(line sessionMessageLine) (Message, error) { return Message{}, err } return Message{ - ID: line.ID, - SenderID: line.SenderID, - Kind: line.Kind, - Content: line.Content, - Event: line.Event, - Metadata: line.Metadata, - CreatedAt: createdAt, - Mentions: line.Mentions, - RelatesTo: line.RelatesTo, - Thread: line.Thread, + ID: line.ID, + SenderID: line.SenderID, + Kind: line.Kind, + Content: line.Content, + Event: line.Event, + Metadata: line.Metadata, + CreatedAt: createdAt, + Mentions: line.Mentions, + RelatesTo: line.RelatesTo, + Thread: line.Thread, + Attachments: cloneMessageAttachments(line.Attachments), }, nil } @@ -198,6 +202,7 @@ func decodeSessionMessageLine(sessionsRoot string, line []byte) (Message, error) message.Content = blob.Content message.Event = blob.Event message.Thread = blob.Thread + message.Attachments = cloneMessageAttachments(blob.Attachments) return message, nil } @@ -307,9 +312,10 @@ func encodeSessionMessageLine(sessionsRoot, roomID string, message Message) ([]b return nil, "", err } blob := sessionMessageBlob{ - Content: message.Content, - Event: message.Event, - Thread: message.Thread, + Content: message.Content, + Event: message.Event, + Thread: message.Thread, + Attachments: message.Attachments, } blobData, err := json.Marshal(blob) if err != nil { @@ -326,6 +332,7 @@ func encodeSessionMessageLine(sessionsRoot, roomID string, message Message) ([]b line.Content = "" line.Event = nil line.Thread = nil + line.Attachments = nil line.BlobRef = relativeRef data, err = json.Marshal(line) if err != nil { diff --git a/internal/runtime/codex/appserver_client.go b/internal/runtime/codex/appserver_client.go index 1a9a2f64..f547753f 100644 --- a/internal/runtime/codex/appserver_client.go +++ b/internal/runtime/codex/appserver_client.go @@ -300,6 +300,12 @@ func (c *appServerClient) logDebug(msg string, args ...any) { } } +func (c *appServerClient) logError(msg string, args ...any) { + if c != nil && c.logger != nil { + c.logger.Error(msg, args...) + } +} + func decodeAppServerNumericID(raw json.RawMessage) (int64, error) { var id int64 if err := json.Unmarshal(raw, &id); err == nil { diff --git a/internal/runtime/codex/appserver_events.go b/internal/runtime/codex/appserver_events.go index 0f60480b..70d3a8fa 100644 --- a/internal/runtime/codex/appserver_events.go +++ b/internal/runtime/codex/appserver_events.go @@ -177,7 +177,11 @@ func (m *appServerManager) handleRawItemNotification(runtimeID string, live *liv itemType := appServerString(item, "type") itemID := appServerString(item, "id") activity := strings.Trim(strings.TrimPrefix(method, "item/")+":"+itemType+":"+itemID, ":") - live.notifyAppServerTurn(threadID, appServerTurnResult{activity: activity, progress: appServerItemIsProgress(itemType)}) + live.notifyAppServerTurn(threadID, appServerTurnResult{ + activity: activity, + progress: appServerItemIsProgress(itemType), + assistantActivity: appServerItemSignalsAssistantActivity(itemType), + }) switch { case method == "item/started" && itemType == "commandExecution": @@ -669,6 +673,11 @@ func appServerItemIsProgress(itemType string) bool { } } +func appServerItemSignalsAssistantActivity(itemType string) bool { + itemType = strings.TrimSpace(itemType) + return itemType != "" && itemType != "userMessage" +} + func appServerToolStatus(item map[string]any, fallback string) string { for _, key := range []string{"status", "state"} { if status := appServerString(item, key); status != "" { diff --git a/internal/runtime/codex/appserver_manager.go b/internal/runtime/codex/appserver_manager.go index 0bcf715a..d55ca207 100644 --- a/internal/runtime/codex/appserver_manager.go +++ b/internal/runtime/codex/appserver_manager.go @@ -17,11 +17,15 @@ import ( "csgclaw/internal/codexcli" ) -const appServerStopTimeout = 3 * time.Second +const ( + appServerStopTimeout = 3 * time.Second + appServerTurnInterruptTimeout = 5 * time.Second +) var ( - appServerSemanticInactivityTimeout = 2 * time.Minute - appServerFirstTurnNoProgressTimeout = 90 * time.Second + appServerSemanticInactivityTimeout = 10 * time.Minute + appServerFirstTurnNoProgressTimeout = 5 * time.Minute + appServerMaximumTurnDuration = 30 * time.Minute ) var appServerCommandContext = codexcli.AppServerCommandContext @@ -615,13 +619,14 @@ type appServerTurnWaiter struct { } type appServerTurnResult struct { - success bool - stopReason string - err error - turnID string - activity string - started bool - progress bool + success bool + stopReason string + err error + turnID string + activity string + started bool + progress bool + assistantActivity bool } func (s *liveSession) registerAppServerTurnWaiter(threadID string) (*appServerTurnWaiter, error) { @@ -726,7 +731,7 @@ func (w *appServerTurnWaiter) setTurnID(turnID string) { func (m *appServerManager) waitAppServerTurn(ctx context.Context, live *liveSession, waiter *appServerTurnWaiter) (PromptResponse, error) { semanticTimeout := appServerSemanticInactivityTimeout if semanticTimeout <= 0 { - semanticTimeout = 2 * time.Minute + semanticTimeout = 10 * time.Minute } noProgressTimeout := appServerFirstTurnNoProgressTimeout if noProgressTimeout <= 0 { @@ -734,6 +739,14 @@ func (m *appServerManager) waitAppServerTurn(ctx context.Context, live *liveSess } semanticTimer := time.NewTimer(semanticTimeout) defer semanticTimer.Stop() + maximumDuration := appServerMaximumTurnDuration + var maximumTimer *time.Timer + var maximumC <-chan time.Time + if maximumDuration > 0 { + maximumTimer = time.NewTimer(maximumDuration) + maximumC = maximumTimer.C + defer maximumTimer.Stop() + } var noProgressTimer *time.Timer var noProgressC <-chan time.Time @@ -788,7 +801,7 @@ func (m *appServerManager) waitAppServerTurn(ctx context.Context, live *liveSess noProgressTimer = time.NewTimer(noProgressTimeout) noProgressC = noProgressTimer.C } - if result.progress { + if result.progress || result.assistantActivity { stopNoProgress() } if result.err != nil { @@ -811,7 +824,7 @@ func (m *appServerManager) waitAppServerTurn(ctx context.Context, live *liveSess "last_activity", strings.TrimSpace(waiter.lastActivity), ) } - return PromptResponse{}, m.appServerTurnTimeoutError(live, waiter, "codex app-server no progress timeout", noProgressTimeout) + return PromptResponse{}, m.failTimedOutAppServerTurn(live, waiter, "initial assistant activity", noProgressTimeout) case <-semanticTimer.C: if live.appClient != nil { live.appClient.logDebug("codex app-server turn semantic inactivity timeout", @@ -822,24 +835,79 @@ func (m *appServerManager) waitAppServerTurn(ctx context.Context, live *liveSess "last_activity", strings.TrimSpace(waiter.lastActivity), ) } - return PromptResponse{}, m.appServerTurnTimeoutError(live, waiter, "codex semantic inactivity timeout", semanticTimeout) + return PromptResponse{}, m.failTimedOutAppServerTurn(live, waiter, "app-server inactivity", semanticTimeout) + case <-maximumC: + return PromptResponse{}, m.failTimedOutAppServerTurn(live, waiter, "maximum turn duration", maximumDuration) case <-ctx.Done(): + m.stopAppServerTurn(live, waiter, "prompt context canceled") return PromptResponse{}, ctx.Err() } } } -func (m *appServerManager) appServerTurnTimeoutError(live *liveSession, waiter *appServerTurnWaiter, marker string, timeout time.Duration) error { - spec := live.spec - return fmt.Errorf("%s after %s: runtime_id=%s thread_id=%s turn_id=%s last_activity=%s stderr_tail=%s", - marker, - timeout, - strings.TrimSpace(spec.RuntimeID), - strings.TrimSpace(waiter.threadID), - strings.TrimSpace(waiter.turnID), - strings.TrimSpace(waiter.lastActivity), - m.stderrTail(spec, 2048), - ) +func (m *appServerManager) failTimedOutAppServerTurn(live *liveSession, waiter *appServerTurnWaiter, reason string, timeout time.Duration) error { + m.stopAppServerTurn(live, waiter, reason) + return fmt.Errorf("Codex stopped responding after %s. The turn was canceled; try again", timeout) +} + +func (m *appServerManager) stopAppServerTurn(live *liveSession, waiter *appServerTurnWaiter, reason string) { + interruptErr := m.interruptAppServerTurn(live, waiter) + stderrTail := m.stderrTail(live.spec, 2048) + if live.appClient != nil { + live.appClient.logError("codex app-server turn stopped", + "runtime_id", strings.TrimSpace(live.spec.RuntimeID), + "thread_id", strings.TrimSpace(waiter.threadID), + "turn_id", strings.TrimSpace(waiter.turnID), + "reason", strings.TrimSpace(reason), + "last_activity", strings.TrimSpace(waiter.lastActivity), + "interrupt_error", interruptErr, + "stderr_tail", stderrTail, + ) + } + if interruptErr == nil { + return + } + stopCtx, cancel := context.WithTimeout(context.Background(), appServerStopTimeout) + defer cancel() + if err := m.Stop(stopCtx, SessionHandle{RuntimeID: live.spec.RuntimeID}); err != nil && live.appClient != nil { + live.appClient.logError("stop codex app-server after turn interrupt failure", + "runtime_id", strings.TrimSpace(live.spec.RuntimeID), + "error", err, + ) + } +} + +func (m *appServerManager) interruptAppServerTurn(live *liveSession, waiter *appServerTurnWaiter) error { + if live == nil || live.appClient == nil { + return fmt.Errorf("codex app-server client is unavailable") + } + threadID := strings.TrimSpace(waiter.threadID) + turnID := strings.TrimSpace(waiter.turnID) + if threadID == "" || turnID == "" { + return fmt.Errorf("codex app-server turn identity is incomplete") + } + + ctx, cancel := context.WithTimeout(context.Background(), appServerTurnInterruptTimeout) + defer cancel() + if _, err := live.appClient.request(ctx, "turn/interrupt", map[string]any{ + "threadId": threadID, + "turnId": turnID, + }); err != nil { + return fmt.Errorf("interrupt codex app-server turn: %w", err) + } + + for { + select { + case result := <-waiter.ch: + if result.err != nil || result.success { + return nil + } + case <-live.done: + return nil + case <-ctx.Done(): + return fmt.Errorf("wait for interrupted codex app-server turn: %w", ctx.Err()) + } + } } func (m *appServerManager) readAppServerStdout(runtimeID string, live *liveSession, stdout io.Reader) { diff --git a/internal/runtime/codex/appserver_manager_test.go b/internal/runtime/codex/appserver_manager_test.go index 8da58243..f4e7bb9d 100644 --- a/internal/runtime/codex/appserver_manager_test.go +++ b/internal/runtime/codex/appserver_manager_test.go @@ -365,12 +365,11 @@ func TestAppServerManagerPromptNoProgressTimeout(t *testing.T) { Prompt: []PromptContentBlock{TextBlock("hang")}, }) if err == nil || - !strings.Contains(err.Error(), "codex app-server no progress timeout") || - !strings.Contains(err.Error(), "runtime_id=runtime-1") || - !strings.Contains(err.Error(), "thread_id=main-thread") || - !strings.Contains(err.Error(), "turn_id=turn-hang") || - !strings.Contains(err.Error(), "stderr_tail=still working") { - t.Fatalf("Prompt() error = %v, want no-progress diagnostic", err) + !strings.Contains(err.Error(), "Codex stopped responding after 25ms") || + !strings.Contains(err.Error(), "turn was canceled") || + strings.Contains(err.Error(), "stderr_tail") || + strings.Contains(err.Error(), "runtime_id") { + t.Fatalf("Prompt() error = %v, want concise canceled-turn message", err) } events := sink.snapshot() if len(events) != 1 || events[0].Kind != SessionEventPromptFailed { @@ -378,6 +377,38 @@ func TestAppServerManagerPromptNoProgressTimeout(t *testing.T) { } } +func TestAppServerManagerReasoningCountsAsInitialActivity(t *testing.T) { + withAppServerHelperCommand(t, "prompt-delayed-reasoning") + originalSemantic := appServerSemanticInactivityTimeout + originalNoProgress := appServerFirstTurnNoProgressTimeout + appServerSemanticInactivityTimeout = 100 * time.Millisecond + appServerFirstTurnNoProgressTimeout = 40 * time.Millisecond + t.Cleanup(func() { + appServerSemanticInactivityTimeout = originalSemantic + appServerFirstTurnNoProgressTimeout = originalNoProgress + }) + + dir := t.TempDir() + spec := testAppServerSessionSpec(dir) + manager := newAppServerManager(testAppServerManagerDeps()) + session, err := manager.Start(context.Background(), spec) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + t.Cleanup(func() { _ = manager.Stop(context.Background(), SessionHandle{RuntimeID: spec.RuntimeID}) }) + + resp, err := manager.Prompt(context.Background(), SessionHandle{RuntimeID: spec.RuntimeID}, PromptRequest{ + SessionID: session.SessionID, + Prompt: []PromptContentBlock{TextBlock("think before answering")}, + }) + if err != nil { + t.Fatalf("Prompt() error = %v", err) + } + if resp.StopReason != StopReasonEndTurn { + t.Fatalf("StopReason = %q, want %q", resp.StopReason, StopReasonEndTurn) + } +} + func TestAppServerManagerMCPToolCallCountsAsProgress(t *testing.T) { withAppServerHelperCommand(t, "prompt-mcp-progress-hangs") originalSemantic := appServerSemanticInactivityTimeout @@ -404,9 +435,9 @@ func TestAppServerManagerMCPToolCallCountsAsProgress(t *testing.T) { Prompt: []PromptContentBlock{TextBlock("hang after mcp progress")}, }) if err == nil || - !strings.Contains(err.Error(), "codex semantic inactivity timeout") || - strings.Contains(err.Error(), "codex app-server no progress timeout") { - t.Fatalf("Prompt() error = %v, want semantic timeout after MCP progress", err) + !strings.Contains(err.Error(), "Codex stopped responding after 100ms") || + !strings.Contains(err.Error(), "turn was canceled") { + t.Fatalf("Prompt() error = %v, want canceled turn after inactivity", err) } events := sink.snapshot() @@ -419,7 +450,7 @@ func TestAppServerManagerMCPToolCallCountsAsProgress(t *testing.T) { t.Fatalf("events[0] = %#v, want MCP tool start", events[0]) } if events[len(events)-1].Kind != SessionEventPromptFailed || - !strings.Contains(events[len(events)-1].Error, "codex semantic inactivity timeout") { + !strings.Contains(events[len(events)-1].Error, "Codex stopped responding after 100ms") { t.Fatalf("last event = %#v, want semantic timeout failure", events[len(events)-1]) } } @@ -440,6 +471,12 @@ func TestAppServerItemIsProgressIncludesInteractiveToolItems(t *testing.T) { if appServerItemIsProgress("reasoning") { t.Fatalf("appServerItemIsProgress(reasoning) = true, want false") } + if !appServerItemSignalsAssistantActivity("reasoning") { + t.Fatal("appServerItemSignalsAssistantActivity(reasoning) = false, want true") + } + if appServerItemSignalsAssistantActivity("userMessage") { + t.Fatal("appServerItemSignalsAssistantActivity(userMessage) = true, want false") + } } func TestAppServerManagerDefaultNoProgressTimeoutIsFastFail(t *testing.T) { @@ -1167,6 +1204,41 @@ func TestAppServerManagerHelperProcess(t *testing.T) { case "turn/start": writeRPCNotification(t, "turn/started", map[string]any{"threadId": "main-thread", "turn": map[string]any{"id": "turn-hang"}}) return rpcResult(msg["id"], map[string]any{"turnId": "turn-hang"}), true + case "turn/interrupt": + assertTurnInterruptParams(t, msg, "main-thread", "turn-hang") + writeRPCNotification(t, "turn/completed", map[string]any{ + "threadId": "main-thread", + "turn": map[string]any{"id": "turn-hang", "status": "interrupted"}, + }) + return rpcResult(msg["id"], map[string]any{}), true + default: + return nil, false + } + }) + case "prompt-delayed-reasoning": + runAppServerHelper(t, func(index int, msg map[string]any) (map[string]any, bool) { + switch msg["method"] { + case "thread/start": + return rpcResult(msg["id"], map[string]any{"threadId": "main-thread"}), true + case "turn/start": + writeRPCNotification(t, "turn/started", map[string]any{"threadId": "main-thread", "turn": map[string]any{"id": "turn-reasoning"}}) + go func() { + time.Sleep(20 * time.Millisecond) + writeRPCNotification(t, "item/started", map[string]any{ + "threadId": "main-thread", + "item": map[string]any{"id": "reasoning-1", "type": "reasoning"}, + }) + time.Sleep(50 * time.Millisecond) + writeRPCNotification(t, "item/completed", map[string]any{ + "threadId": "main-thread", + "item": map[string]any{"id": "message-1", "type": "agentMessage", "text": "done"}, + }) + writeRPCNotification(t, "turn/completed", map[string]any{ + "threadId": "main-thread", + "turn": map[string]any{"id": "turn-reasoning", "status": "completed"}, + }) + }() + return rpcResult(msg["id"], map[string]any{"turnId": "turn-reasoning"}), true default: return nil, false } @@ -1190,6 +1262,13 @@ func TestAppServerManagerHelperProcess(t *testing.T) { }, }) return rpcResult(msg["id"], map[string]any{"turnId": "turn-mcp-hang"}), true + case "turn/interrupt": + assertTurnInterruptParams(t, msg, "main-thread", "turn-mcp-hang") + writeRPCNotification(t, "turn/completed", map[string]any{ + "threadId": "main-thread", + "turn": map[string]any{"id": "turn-mcp-hang", "status": "interrupted"}, + }) + return rpcResult(msg["id"], map[string]any{}), true default: return nil, false } @@ -1401,6 +1480,20 @@ func assertTurnStartParams(t *testing.T, msg map[string]any, wantThreadID string } } +func assertTurnInterruptParams(t *testing.T, msg map[string]any, wantThreadID, wantTurnID string) { + t.Helper() + params, _ := msg["params"].(map[string]any) + if params == nil { + t.Fatalf("turn/interrupt params = %#v, want object", msg["params"]) + } + if got := params["threadId"]; got != wantThreadID { + t.Fatalf("turn/interrupt threadId = %#v, want %q", got, wantThreadID) + } + if got := params["turnId"]; got != wantTurnID { + t.Fatalf("turn/interrupt turnId = %#v, want %q", got, wantTurnID) + } +} + func writeRPCServerRequest(t *testing.T, id int, method string, params any) { t.Helper() msg := map[string]any{ diff --git a/internal/runtime/codex/runtime.go b/internal/runtime/codex/runtime.go index 475c7031..4f57dacd 100644 --- a/internal/runtime/codex/runtime.go +++ b/internal/runtime/codex/runtime.go @@ -450,7 +450,7 @@ func (r *Runtime) ensureSession(ctx context.Context, spec SessionSpec) (*Session if err := r.mkdirAll(spec.WorkspaceDir, 0o755); err != nil { return nil, fmt.Errorf("create codex workspace dir %s: %w", spec.WorkspaceDir, err) } - if err := r.seedCodexHomeAuth(spec.CodexHomeDir); err != nil { + if err := r.seedCodexHomeAuth(spec.CodexHomeDir, spec.Profile); err != nil { return nil, err } if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.WorkspaceDir, spec.Profile, agentRef.MCPServers); err != nil { @@ -544,7 +544,7 @@ func (r *Runtime) hydratePersistedSession(ctx context.Context, manager *appServe if err := r.mkdirAll(spec.WorkspaceDir, 0o755); err != nil { return nil, fmt.Errorf("create codex workspace dir %s: %w", spec.WorkspaceDir, err) } - if err := r.seedCodexHomeAuth(spec.CodexHomeDir); err != nil { + if err := r.seedCodexHomeAuth(spec.CodexHomeDir, spec.Profile); err != nil { return nil, err } if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.WorkspaceDir, spec.Profile, agentRef.MCPServers); err != nil { @@ -591,13 +591,20 @@ func firstNonEmpty(values ...string) string { return "" } -func (r *Runtime) seedCodexHomeAuth(runtimeCodexHome string) error { +func (r *Runtime) seedCodexHomeAuth(runtimeCodexHome string, profile agentruntime.Profile) error { runtimeCodexHome = strings.TrimSpace(runtimeCodexHome) if runtimeCodexHome == "" { return fmt.Errorf("codex home dir is required") } runtimeAuthPath := filepath.Join(runtimeCodexHome, "auth.json") + profile = profile.Normalized() + if profile.BaseURL != "" && profile.APIKey != "" { + if err := r.removeAll(runtimeAuthPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove runtime codex auth %s: %w", runtimeAuthPath, err) + } + return nil + } if _, err := r.readFile(runtimeAuthPath); err == nil { return nil } else if !errors.Is(err, os.ErrNotExist) { diff --git a/internal/runtime/codex/runtime_test.go b/internal/runtime/codex/runtime_test.go index 6c956dcf..aeb802a0 100644 --- a/internal/runtime/codex/runtime_test.go +++ b/internal/runtime/codex/runtime_test.go @@ -187,12 +187,8 @@ func TestRuntimeCreateStartAndInfo(t *testing.T) { t.Fatalf("runtime metadata binary path = %q", meta.BinaryPath) } - authRaw, err := os.ReadFile(filepath.Join(root, "agent-alice", ".codex", "home", "auth.json")) - if err != nil { - t.Fatalf("read seeded runtime auth: %v", err) - } - if string(authRaw) != `{"tokens":{"access_token":"access","refresh_token":"refresh"}}` { - t.Fatalf("runtime auth = %q, want copied host auth", string(authRaw)) + if _, err := os.Stat(filepath.Join(root, "agent-alice", ".codex", "home", "auth.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed proxy runtime auth stat error = %v, want missing", err) } assertRuntimeConfigContains(t, filepath.Join(root, "agent-alice", ".codex", "home", configFileName), @@ -366,6 +362,12 @@ func TestRefreshCodexHomeAgentsFileAddsManagerConnectorRules(t *testing.T) { "Do not rely on connector tokens from environment variables", "external Codex GitHub app connector", "reconnect the CSGClaw GitHub OAuth connector", + "Historical Attachment Recovery", + "csgclaw-cli message list --channel --room-id ", + "jq '[.[] as $message | ($message.attachments // [])[]", + "/api/v1/attachments/", + "curl -fsS -H \"Authorization: Bearer ${CSGCLAW_ACCESS_TOKEN:?}\"", + "until durable CSGClaw history has been checked", } { if !strings.Contains(text, want) { t.Fatalf("manager AGENTS.md missing %q in %q", want, text) @@ -946,6 +948,70 @@ func TestRuntimeCreateKeepsExistingRuntimeAuth(t *testing.T) { ) } +func TestRuntimeCreateRemovesStaleAuthForManagedProxy(t *testing.T) { + root := t.TempDir() + runtimeAuthPath := filepath.Join(root, "agent-alice", ".codex", "home", "auth.json") + if err := os.MkdirAll(filepath.Dir(runtimeAuthPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(runtimeAuthPath, []byte(`{"tokens":{"refresh_token":"stale-refresh"}}`), 0o600); err != nil { + t.Fatal(err) + } + + rt := New(Dependencies{ + BinaryProvider: fakeBinaryProvider{path: "/tmp/codex"}, + AgentHome: func(agentName string) (string, error) { + return filepath.Join(root, agentName), nil + }, + ResolveAgent: func(h agentruntime.Handle) (AgentRef, error) { + return AgentRef{ + ID: "u-alice", + Name: "alice", + RuntimeID: h.RuntimeID, + Profile: agentruntime.Profile{ + ModelID: "gpt-5.5", + BaseURL: "https://runtime.example/v1", + APIKey: "runtime-key", + }, + }, nil + }, + Manager: fakeManager{ + start: func(_ context.Context, spec SessionSpec) (*Session, error) { + return &Session{ + RuntimeID: spec.RuntimeID, + AgentID: spec.AgentID, + AgentName: spec.AgentName, + SessionID: "sess-managed-auth", + BinaryPath: spec.BinaryPath, + WorkspaceDir: spec.WorkspaceDir, + HomeDir: spec.HomeDir, + CodexHomeDir: spec.CodexHomeDir, + StderrPath: spec.StderrPath, + ProcessID: os.Getpid(), + CreatedAt: time.Now().UTC(), + StartedAt: time.Now().UTC(), + }, nil + }, + }, + }) + + if _, err := rt.New(context.Background(), agentruntime.Spec{ + RuntimeID: "rt-u-alice", + AgentID: "u-alice", + AgentName: "alice", + Profile: agentruntime.Profile{ + ModelID: "gpt-5.5", + BaseURL: "https://runtime.example/v1", + APIKey: "runtime-key", + }, + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := os.Stat(runtimeAuthPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed proxy runtime auth stat error = %v, want missing", err) + } +} + func TestRuntimeCreateWritesConfigWhenHostAuthIsSeeded(t *testing.T) { root := t.TempDir() hostHome := t.TempDir() diff --git a/web/app/src/api/im.ts b/web/app/src/api/im.ts index 52f1910e..1e9a6835 100644 --- a/web/app/src/api/im.ts +++ b/web/app/src/api/im.ts @@ -2,6 +2,7 @@ import { del, get, post } from "@/api/client"; import type { IMConversation, IMMessage, IMUser, MessageRelation, ThreadView } from "@/models/conversations"; export type SendMessagePayload = { + attachments?: File[]; content: string; relates_to?: MessageRelation | null; room_id: string; @@ -51,6 +52,15 @@ export type CreateUserPayload = Partial & { }; export function sendMessageRequest(payload: SendMessagePayload): Promise { + if (payload.attachments?.length) { + const formData = new FormData(); + const { attachments, ...messagePayload } = payload; + formData.set("payload", JSON.stringify(messagePayload)); + attachments.forEach((file) => { + formData.append("files", file, file.name); + }); + return post("api/v1/messages", undefined, { body: formData }); + } return post("api/v1/messages", payload); } diff --git a/web/app/src/components/business/ConversationPane/ConversationAttachments.tsx b/web/app/src/components/business/ConversationPane/ConversationAttachments.tsx new file mode 100644 index 00000000..f1be5679 --- /dev/null +++ b/web/app/src/components/business/ConversationPane/ConversationAttachments.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { FileText, X } from "lucide-react"; +import { Button } from "@/components/ui"; +import { + formatAttachmentSize, + isImageAttachment, + type AttachmentDraft, + type MessageAttachment, +} from "@/models/attachments"; +import type { TranslateFn } from "@/models/conversations"; + +export function AttachmentDraftStrip({ + drafts, + t, + onRemove, +}: { + drafts: readonly AttachmentDraft[]; + t: TranslateFn; + onRemove: (id: string) => void; +}) { + if (drafts.length === 0) { + return null; + } + return ( +
+ {drafts.map((draft) => ( + + ))} +
+ ); +} + +function AttachmentDraftItem({ + draft, + t, + onRemove, +}: { + draft: AttachmentDraft; + t: TranslateFn; + onRemove: (id: string) => void; +}) { + const previewURL = useObjectURL(isImageAttachment(draft) ? draft.file : null); + return ( +
+ {previewURL ? ( + + ) : ( + + )} + + {draft.name} + {formatAttachmentSize(draft.sizeBytes)} + + +
+ ); +} + +export function MessageAttachments({ + attachments, + t, +}: { + attachments?: readonly MessageAttachment[] | null; + t: TranslateFn; +}) { + if (!attachments?.length) { + return null; + } + const images = attachments.filter(isImageAttachment); + const files = attachments.filter((attachment) => !isImageAttachment(attachment)); + return ( +
+ {images.length > 0 ? ( +
+ {images.map((attachment) => ( + + {attachment.name} + + ))} +
+ ) : null} + {files.length > 0 ? ( + + ) : null} +
+ ); +} + +function useObjectURL(file: File | null): string { + const [url, setURL] = useState(""); + useEffect(() => { + if (!file) { + setURL(""); + return undefined; + } + const nextURL = URL.createObjectURL(file); + setURL(nextURL); + return () => URL.revokeObjectURL(nextURL); + }, [file]); + return url; +} diff --git a/web/app/src/components/business/ConversationPane/ConversationComposer.tsx b/web/app/src/components/business/ConversationPane/ConversationComposer.tsx index bb51e358..57d48280 100644 --- a/web/app/src/components/business/ConversationPane/ConversationComposer.tsx +++ b/web/app/src/components/business/ConversationPane/ConversationComposer.tsx @@ -1,11 +1,12 @@ -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useMemo, useRef } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react"; -import { Link2 } from "lucide-react"; +import { ArrowUp, Paperclip, Plus } from "lucide-react"; import { CLIProxyAuthControl } from "@/components/business/ProfileControls"; -import { Button } from "@/components/ui"; +import { Button, PopoverClose, PopoverContent, PopoverRoot, PopoverTrigger } from "@/components/ui"; import { IconImage } from "@/components/ui/Icons"; import type { CLIProxyAuthStatusMap } from "@/hooks/workspace/useCLIProxyAuthStatuses"; import type { AgentProfileLike } from "@/models/agents"; +import type { AttachmentDraft } from "@/models/attachments"; import { providerNeedsAuth } from "@/models/agents"; import { insertComposerSegmentsAtSelection, @@ -20,6 +21,8 @@ import type { TranslateFn } from "@/models/conversations"; import type { SlashPickerCandidate } from "@/models/slashCommands"; import { MentionPicker } from "./MentionPicker"; import { SlashPicker } from "./SlashPicker"; +import { AttachmentDraftStrip } from "./ConversationAttachments"; +import { filesFromDataTransfer } from "./attachmentFiles"; import type { ConversationWorkingParticipant, MentionPickerUser, VoidOrPromise } from "./types"; export type ConversationComposerProps = { @@ -34,6 +37,7 @@ export type ConversationComposerProps = { composerError: string; draftSegments: ComposerSegment[]; draftText: string; + attachmentDrafts?: AttachmentDraft[]; editorRef: RefObject; managerProfile?: AgentProfileLike | null; managerProvider: string; @@ -42,6 +46,7 @@ export type ConversationComposerProps = { mentionableUsersByName: Map; onApplyMention: (user: MentionPickerUser) => void; onApplySlashCandidate: (name: string) => void; + onAddAttachments?: (files: File[]) => void; onComposerCompositionEnd: () => void; onComposerCompositionStart: () => void; onComposerKeyDown: (event: ReactKeyboardEvent) => void; @@ -51,6 +56,7 @@ export type ConversationComposerProps = { onProviderLogin: (provider: string) => VoidOrPromise; onSaveConnectorConfig?: (draft: ConnectorConfigDraft) => VoidOrPromise; onSendMessage: () => VoidOrPromise; + onRemoveAttachment?: (id: string) => void; onSyncComposer: () => void; slashCandidates: SlashPickerCandidate[]; slashIndex: number; @@ -72,6 +78,7 @@ export const ConversationComposer = memo(function ConversationComposer({ composerError, draftSegments, draftText, + attachmentDrafts = [], editorRef, managerProfile, managerProvider, @@ -86,6 +93,7 @@ export const ConversationComposer = memo(function ConversationComposer({ workingParticipants = [], onApplyMention, onApplySlashCandidate, + onAddAttachments = () => {}, onComposerCompositionEnd, onComposerCompositionStart, onComposerKeyDown, @@ -93,11 +101,21 @@ export const ConversationComposer = memo(function ConversationComposer({ onDisconnectConnector, onManageConnector, onProviderLogin, + onRemoveAttachment = () => {}, onSendMessage, onSyncComposer, }: ConversationComposerProps) { const defaultConnectorStatus = useMemo(() => emptyGitHubConnectorStatus(), []); const githubStatus = connectorStatus ?? defaultConnectorStatus; + const fileInputRef = useRef(null); + const sendDisabled = composerDisabled || (!draftText.trim() && attachmentDrafts.length === 0); + + function handleFiles(files: File[]) { + if (composerDisabled || files.length === 0) { + return; + } + onAddAttachments(files); + } return (
@@ -125,8 +143,25 @@ export const ConversationComposer = memo(function ConversationComposer({ /> ) : null} {workingParticipants.length > 0 ? : null} -
-
+
{ + if (composerDisabled || filesFromDataTransfer(event.dataTransfer).length === 0) { + return; + } + event.preventDefault(); + }} + onDrop={(event) => { + const files = filesFromDataTransfer(event.dataTransfer); + if (files.length === 0) { + return; + } + event.preventDefault(); + handleFiles(files); + }} + > + +
{draftSegments.length === 0 ? ( +
+ fileInputRef.current?.click()} + onConnect={onConnectConnector} + onDisconnect={onDisconnectConnector} + onManage={onManageConnector} + /> + { + handleFiles(Array.from(event.currentTarget.files || [])); + event.currentTarget.value = ""; + }} + />
{composerError ?
{composerError}
: null} -
); }); @@ -208,29 +267,31 @@ function ComposerWorkingIndicator({ ); } -type ConnectorMenuProps = { +type ComposerAddMenuProps = { busyAction: string; + disabled: boolean; error: string; pending: boolean; status: ConnectorStatus; t: TranslateFn; + onAddFiles: () => void; onConnect?: () => VoidOrPromise; onDisconnect?: () => VoidOrPromise; onManage?: () => VoidOrPromise; }; -function ConnectorMenu({ +function ComposerAddMenu({ busyAction, + disabled, error, pending, status, t, + onAddFiles, onConnect, onDisconnect, onManage, -}: ConnectorMenuProps) { - const menuRef = useRef(null); - const [open, setOpen] = useState(false); +}: ComposerAddMenuProps) { const accountLabel = status.account?.login || status.account?.name || ""; const connectorStateLabel = status.connected && accountLabel @@ -238,24 +299,8 @@ function ConnectorMenu({ : status.connected ? t("connectorConnected") : t("connectorNotConnected"); - const title = error || (pending ? t("connectorOAuthPending") : t("connectorManagerTitle")); const busy = pending || busyAction === "connect"; - useEffect(() => { - if (!open) { - return undefined; - } - function handlePointerDown(event: MouseEvent) { - const menu = menuRef.current; - if (!menu || !(event.target instanceof Node) || menu.contains(event.target)) { - return; - } - setOpen(false); - } - document.addEventListener("mousedown", handlePointerDown); - return () => document.removeEventListener("mousedown", handlePointerDown); - }, [open]); - function handleConnectGitHub() { void onConnect?.(); } @@ -269,87 +314,91 @@ function ConnectorMenu({ } return ( -
-
+ + - {open ? ( -
-
-
-
-
- -
- {t("connectorGitHub")} - {connectorStateLabel} -
+ + +
+
{t("composerAdd")}
+ + + +
+
+
+
{t("composerConnectors")}
+
+
+ +
+ {t("connectorGitHub")} + {connectorStateLabel}
- {status.connected ? ( -
- {t("connectorConnected")} - {status.app_manageable ? ( - - ) : null} +
+ {status.connected ? ( +
+ {t("connectorConnected")} + {status.app_manageable ? ( -
- ) : ( + ) : null} - )} -
- {pending ? ( -
- {t("connectorOAuthPending")}
- ) : null} - {error ?
{error}
: null} + ) : ( + + )}
- ) : null} -
-
{t("composerTip")}
-
+ {pending ? ( +
+ {t("connectorOAuthPending")} +
+ ) : null} + {error ?
{error}
: null} + + + ); } diff --git a/web/app/src/components/business/ConversationPane/ConversationMessageActions.tsx b/web/app/src/components/business/ConversationPane/ConversationMessageActions.tsx new file mode 100644 index 00000000..c886254c --- /dev/null +++ b/web/app/src/components/business/ConversationPane/ConversationMessageActions.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Copy, MessageSquareReply } from "lucide-react"; +import { flattenMentionText } from "@/components/business/MessageContent"; +import type { TranslateFn } from "@/models/conversations"; +import type { VoidOrPromise } from "./types"; + +export type ConversationMessageActionsProps = { + className?: string; + content?: string | null; + onOpenThread?: () => VoidOrPromise; + t: TranslateFn; +}; + +export function ConversationMessageActions({ + className = "", + content, + onOpenThread, + t, +}: ConversationMessageActionsProps) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef(null); + const copyText = flattenMentionText(content); + const canCopy = Boolean(copyText.replace(/\u200b/g, "")); + const copyLabel = copied ? t("copiedToClipboard") : t("copyToClipboard"); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + window.clearTimeout(copiedTimerRef.current); + } + }, + [], + ); + + async function copyMessage() { + if (!canCopy || !(await writeTextToClipboard(copyText))) { + return; + } + setCopied(true); + if (copiedTimerRef.current != null) { + window.clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = window.setTimeout(() => { + copiedTimerRef.current = null; + setCopied(false); + }, 2000); + } + + if (!canCopy && !onOpenThread) { + return null; + } + + return ( +
+ {canCopy ? ( + + ) : null} + {onOpenThread ? ( + + ) : null} +
+ ); +} + +async function writeTextToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + textarea.remove(); + } + } +} diff --git a/web/app/src/components/business/ConversationPane/ConversationMessageList.tsx b/web/app/src/components/business/ConversationPane/ConversationMessageList.tsx index e7ff7617..21ba5d8d 100644 --- a/web/app/src/components/business/ConversationPane/ConversationMessageList.tsx +++ b/web/app/src/components/business/ConversationPane/ConversationMessageList.tsx @@ -13,6 +13,7 @@ import { isEventMessage, localIdentitiesMatch, resolveUserByLocalIdentity, + threadHasReplies, type IMConversation, type IMMessage, type IMUser, @@ -23,6 +24,8 @@ import { import { avatarFallbackText } from "@/shared/avatar"; import type { ThemeMode } from "@/shared/theme/theme"; import { MessageTimestamp, MessageTimeDivider } from "./MessageTime"; +import { MessageAttachments } from "./ConversationAttachments"; +import { ConversationMessageActions } from "./ConversationMessageActions"; import { shouldShowMessageDateDivider } from "./messageTimeUtils"; import type { VoidOrPromise } from "./types"; @@ -115,7 +118,7 @@ export const ConversationMessageList = memo(function ConversationMessageList({ const messageAvatarFallback = messageAgent ? resolveAgentAvatarFallback(messageAgent, usersById) : avatarFallbackText(user.avatar, user.name, user.id); - const threadSummary = message.thread; + const threadSummary = threadHasReplies(message.thread) ? message.thread : null; const latestThreadReply = threadSummary?.latest_reply; const messageStateKey = longMessageStateKey(conversation, message, index); return ( @@ -145,46 +148,35 @@ export const ConversationMessageList = memo(function ConversationMessageList({ ) : null}
-
- -
{user.name}
-
- - setExpandedLongMessages((current) => ({ - ...current, - [messageStateKey]: expanded, - })) - : undefined - } - t={t} - /> -
+ {message.content ? ( +
+ + setExpandedLongMessages((current) => ({ + ...current, + [messageStateKey]: expanded, + })) + : undefined + } + t={t} + /> +
+ ) : null} + {threadSummary ? (
) : ( @@ -206,6 +202,12 @@ export const ConversationMessageList = memo(function ConversationMessageList({ )}
) : null} + onOpenThread(message)} + t={t} + />
diff --git a/web/app/src/components/business/ConversationPane/ConversationPane.css b/web/app/src/components/business/ConversationPane/ConversationPane.css index 69a39f64..46f52ad8 100644 --- a/web/app/src/components/business/ConversationPane/ConversationPane.css +++ b/web/app/src/components/business/ConversationPane/ConversationPane.css @@ -372,14 +372,33 @@ } .composer { + --composer-add-control-size: 40px; + --composer-add-icon-size: 22px; + --composer-send-control-size: 44px; + --composer-send-icon-size: 20px; + position: relative; isolation: isolate; + padding: 10px 16px 12px; + border-top: 1px solid var(--line); + background: var(--surface); } .composer-box { - display: block; + display: grid; position: relative; z-index: 1; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + transition: + border-color 140ms ease, + box-shadow 140ms ease; +} + +.composer-box:focus-within { + border-color: color-mix(in srgb, var(--brand-500) 58%, var(--line)); + box-shadow: var(--focus-ring); } .composer-working { @@ -439,13 +458,17 @@ } } -.composer-input-wrap { +.composer-editor-wrap { position: relative; } .composer-placeholder { position: absolute; + top: 12px; + right: 14px; + left: 14px; color: var(--muted); + font-size: 13px; pointer-events: none; line-height: 1.5; white-space: nowrap; @@ -455,8 +478,14 @@ .composer-editor { width: 100%; + min-height: 52px; max-height: 168px; + padding: 12px 14px 6px; + border: 0; + background: transparent; + color: var(--text); font: inherit; + font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; @@ -465,6 +494,23 @@ outline: none; } +.composer-toolbar { + display: flex; + min-height: calc(var(--composer-send-control-size) + 8px); + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 6px 6px; +} + +:root[data-theme="dark"] .composer .composer-box { + background: var(--surface); +} + +:root[data-theme="dark"] .composer .composer-editor { + background: transparent; +} + .composer-editor.disabled { opacity: 0.58; cursor: not-allowed; @@ -489,171 +535,277 @@ white-space: nowrap; } -.composer-send-button { - position: absolute; - display: inline-flex; - align-items: center; - justify-content: center; - border: 0; - background: var(--primary); - color: var(--on-primary); - cursor: pointer; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); - transition: - transform 160ms ease, - box-shadow 160ms ease, - opacity 160ms ease; +.attachment-draft-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + padding: 10px 12px 0; } -.composer-send-main { - display: inline-flex; +.attachment-draft, +.message-file-attachment { + min-width: 0; + max-width: min(280px, 100%); + min-height: 44px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--field); + color: var(--text); + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + gap: 8px; align-items: center; - justify-content: center; + text-decoration: none; } -.composer-send-main svg { - width: 24px; - height: 24px; - fill: currentColor; +.attachment-draft-preview, +.message-image-attachment img { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + background: var(--panel); } -.composer-send-button:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.84), - 0 10px 18px rgba(84, 120, 214, 0.22); +.attachment-file-icon { + width: 32px; + height: 32px; + border-radius: 6px; + display: grid; + place-items: center; + background: var(--panel); + color: var(--muted); } -.composer-send-button:focus-visible { - outline: none; - box-shadow: 0 0 0 4px rgba(43, 109, 246, 0.16); +.attachment-draft-meta { + min-width: 0; + display: grid; + gap: 2px; } -.composer-send-button:disabled { - opacity: 0.45; - cursor: not-allowed; - transform: none; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); +.attachment-name { + min-width: 0; + font-size: 13px; + font-weight: 650; } -.composer-tip { - margin-top: 10px; +.attachment-size { + color: var(--muted); + font-size: 12px; } -.composer-actions-row { - display: flex; - align-items: center; - gap: 12px; - min-height: 34px; - margin-top: 8px; +.attachment-remove-button, +.btn.attachment-remove-button { + width: 28px; + height: 28px; + padding: 0; } -.composer-connector-menu { - position: relative; - display: inline-flex; - align-items: center; +.message-attachments { + margin-top: 8px; + display: grid; + gap: 8px; + max-width: min(420px, 100%); } -.composer-tool-button, -.btn.composer-tool-button { - width: 34px; - height: 34px; - min-width: 34px; - min-height: 34px; - padding: 0; - border-radius: 999px; +.message-row.own .message-attachments { + justify-items: end; + margin-left: auto; } -.composer-tool-button svg { - width: 18px; - height: 18px; +.message-attachment-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(104px, 148px)); + gap: 8px; + width: min(304px, 100%); } -.composer-actions-row .composer-tip { +.message-image-attachment { + display: block; min-width: 0; - margin-top: 0; + aspect-ratio: 1; + border: 1px solid var(--line); + border-radius: var(--radius-md); overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + background: var(--field); } -.composer-connector-popover { - position: absolute; - left: 0; - bottom: calc(100% + 8px); - z-index: var(--z-portal-popover); - width: min(360px, calc(100vw - 40px)); - padding: 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: color-mix(in srgb, var(--surface) 96%, transparent); - box-shadow: var(--shadow); +.message-image-attachment img { + width: 100%; + height: 100%; + border-radius: 0; +} + +.message-image-attachment:hover, +.message-image-attachment:focus-visible, +.message-file-attachment:hover, +.message-file-attachment:focus-visible { + border-color: var(--primary); + outline: none; +} + +.message-file-list { display: grid; - gap: 10px; + gap: 6px; } -.connector-popover-header, -.connector-provider-row, -.connector-provider-main { +.message-file-attachment { + grid-template-columns: 32px minmax(0, 1fr); +} + +.composer-send-button, +.btn.composer-send-button { + width: var(--composer-send-control-size); + height: var(--composer-send-control-size); + min-width: var(--composer-send-control-size); + min-height: var(--composer-send-control-size); + padding: 0; + border-radius: 50%; +} + +.composer-add-button, +.btn.composer-add-button { + width: var(--composer-add-control-size); + height: var(--composer-add-control-size); + min-width: var(--composer-add-control-size); + min-height: var(--composer-add-control-size); + padding: 0; + border-radius: 50%; + color: var(--text); +} + +.composer-add-button svg { + width: var(--composer-add-icon-size); + height: var(--composer-add-icon-size); +} + +.composer-send-button svg { + width: var(--composer-send-icon-size); + height: var(--composer-send-icon-size); +} + +.composer-add-button:hover:not(:disabled), +.btn.composer-add-button:hover:not(:disabled) { + background: var(--field); + color: var(--text); +} + +.composer-add-popover { + width: min(270px, calc(100vw - 20px)); + max-height: min(340px, calc(100dvh - 20px)); + overflow-y: auto; + padding: 5px; + display: grid; + gap: 1px; + font-size: 12px; +} + +@media (max-width: 720px) { + .composer-add-popover { + max-height: min(150px, calc(100dvh - 16px)); + } +} + +.composer-add-section { + display: grid; + gap: 1px; +} + +.composer-add-section-label { + padding: 3px 6px 1px; + color: var(--muted); + font-size: 10px; + font-weight: 600; + line-height: 14px; +} + +.composer-add-menu-item { + width: 100%; + min-height: 30px; display: flex; align-items: center; + gap: 7px; + padding: 5px 6px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; } -.connector-popover-header { - gap: 10px; - color: var(--text); +.composer-add-menu-item svg { + width: 14px; + height: 14px; } -.connector-popover-header strong { - font-size: 15px; - font-weight: 700; +.composer-add-menu-item:hover, +.composer-add-menu-item:focus-visible { + outline: none; + background: var(--field); +} + +.composer-add-separator { + height: 1px; + margin: 2px 3px; + background: var(--line); +} + +.connector-provider-row, +.connector-provider-main { + display: flex; + align-items: center; } .connector-provider-row { min-width: 0; flex-wrap: wrap; justify-content: space-between; - gap: 12px; - min-height: 54px; - padding: 8px 10px; - border-radius: 10px; - background: var(--field); + gap: 6px; + min-height: 36px; + padding: 3px 6px; + border-radius: 6px; + background: transparent; } .connector-provider-main { - flex: 1 1 120px; + flex: 1 1 90px; min-width: 0; - gap: 10px; + gap: 6px; } .connector-provider-icon { - width: 30px; - height: 30px; - flex: 0 0 30px; + width: 20px; + height: 20px; + flex: 0 0 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; - background: var(--panel); + background: transparent; color: var(--text); } .connector-provider-icon .svg-icon { - width: 18px; - height: 18px; + width: 14px; + height: 14px; background: currentColor; } .connector-provider-copy { min-width: 0; display: grid; - gap: 2px; + gap: 1px; } .connector-provider-copy strong { overflow: hidden; color: var(--text); - font-size: 14px; + font-size: 12px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; @@ -663,7 +815,7 @@ .connector-provider-copy span { overflow: hidden; color: var(--muted); - font-size: 12px; + font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; @@ -681,17 +833,17 @@ flex-wrap: wrap; align-items: center; justify-content: flex-end; - gap: 8px; + gap: 4px; } .connector-connected-state { flex: 0 0 auto; - padding: 4px 8px; + padding: 2px 5px; border: 1px solid color-mix(in oklab, var(--success-700) 28%, var(--line)); border-radius: 999px; background: var(--success-50); color: var(--success-700); - font-size: 12px; + font-size: 10px; font-weight: 700; line-height: 1; } @@ -705,7 +857,15 @@ .connector-manage-button, .btn.connector-manage-button, .connector-disconnect-button, -.btn.connector-disconnect-button { +.btn.connector-disconnect-button, +.connector-connect-button, +.btn.connector-connect-button { + --btn-height: 24px; + --btn-min-width: 0px; + --btn-padding-x: 5px; + --btn-font-size: 10px; + --btn-line-height: 12px; + flex: 0 0 auto; } @@ -713,7 +873,7 @@ .connector-form-error { margin: 0; color: var(--muted); - font-size: 12px; + font-size: 10px; } .mention-picker { @@ -911,49 +1071,6 @@ auto; } -.composer { - border-top-color: var(--line); - background: linear-gradient(180deg, oklch(0.77 0.16 164 / 0.035), transparent), var(--surface); -} - -.composer-box { - border: 1px solid var(--line); -} - -.composer-input-wrap { - min-height: 74px; -} - -.composer-editor { - min-height: 74px; - padding: 19px 84px 18px 18px; - border: 0; - background: transparent; - box-shadow: none; -} - -.composer-editor:focus { - box-shadow: - inset 0 0 0 1px oklch(0.77 0.16 164 / 0.42), - var(--focus-ring); -} - -.composer-placeholder { - top: 19px; - left: 18px; -} - -.composer-send-button { - right: 12px; - bottom: 12px; - border-radius: 8px; -} - -.composer-tip { - color: color-mix(in oklab, var(--muted) 84%, var(--primary)); - font-size: 12px; -} - .empty-state, .messages-empty { color: var(--muted); @@ -996,14 +1113,6 @@ } } -.composer-send-button { - width: 44px; - height: 40px; - min-width: 44px; - min-height: 40px; - padding: 0; -} - .chat-panel { border: 0; background: var(--panel); @@ -1077,95 +1186,6 @@ color: var(--admin-text); } -.composer { - padding: 16px 22px; - border-top: 1px solid var(--line); -} - -.composer-box { - border-color: var(--line); - border-radius: var(--radius-xl); - background: var(--field); - box-shadow: var(--shadow-xs); -} - -:root[data-theme="dark"] .composer-box { - box-shadow: none; -} - -.composer-editor { - border-radius: var(--radius-xl); - color: var(--text); -} - -.composer-editor { - padding-right: 64px; -} - -.composer-placeholder { - right: 64px; -} - -.composer-send-button, -.btn.composer-send-button { - right: 12px; - bottom: 12px; - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - padding: 0; - border: 1px solid var(--brand-600); - border-radius: var(--radius-md); - background: var(--brand-600); - color: var(--white); - box-shadow: var(--shadow-xs); -} - -.composer-send-button:hover:not(:disabled), -.btn.composer-send-button:hover:not(:disabled) { - border-color: var(--brand-700); - background: var(--brand-700); - color: var(--white); - box-shadow: var(--shadow-xs); - transform: none; -} - -.composer-send-button:active:not(:disabled), -.btn.composer-send-button:active:not(:disabled) { - border-color: var(--brand-700); - background: var(--brand-700); - color: var(--white); - box-shadow: var(--shadow-xs); - transform: none; -} - -.composer-send-button:focus-visible, -.btn.composer-send-button:focus-visible { - border-color: var(--brand-600); - background: var(--brand-600); - color: var(--white); - box-shadow: - var(--shadow-xs), - 0 0 0 var(--space-1) color-mix(in srgb, var(--brand-500) 24%, transparent); -} - -.composer-send-button:disabled, -.composer-send-button[disabled], -.btn.composer-send-button:disabled, -.btn.composer-send-button[disabled] { - border-color: var(--gray-200); - background: var(--gray-100); - color: var(--gray-400); - box-shadow: var(--shadow-xs); - opacity: 1; -} - -.composer-send-main { - width: 14px; - height: 14px; -} - @media (max-width: 720px) { .messages { padding: 18px; @@ -1651,52 +1671,6 @@ opacity: 1; } -.composer { - padding: 12px 20px 10px; - background: var(--surface); -} - -.composer-box { - border-radius: 12px; - background: var(--panel); - box-shadow: var(--shadow-sm); -} - -.composer-input-wrap { - min-height: 56px; -} - -.composer-editor { - min-height: 56px; - padding: 12px 54px 12px 16px; - border-radius: 12px; -} - -.composer-placeholder { - top: 12px; - left: 16px; - right: 54px; - font-size: 14px; -} - -.composer-send-button, -.btn.composer-send-button { - position: absolute; - right: 10px; - bottom: 10px; - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - border-radius: 8px; -} - -.composer-tip { - margin-top: 9px; - color: var(--muted); - font-size: 12px; -} - :root[data-theme="dark"] .message-row:not(.own) .message-bubble, :root[data-theme="dark"] .message-row.admin .message-bubble { border-color: transparent; @@ -1704,10 +1678,6 @@ color: var(--text); } -:root[data-theme="dark"] .composer-box { - background: var(--panel-strong); -} - .mention-picker { width: min(360px, calc(100% - 40px)); padding: 6px; diff --git a/web/app/src/components/business/ConversationPane/ConversationThreadPanel.tsx b/web/app/src/components/business/ConversationPane/ConversationThreadPanel.tsx index 4acf78ed..44f43ace 100644 --- a/web/app/src/components/business/ConversationPane/ConversationThreadPanel.tsx +++ b/web/app/src/components/business/ConversationPane/ConversationThreadPanel.tsx @@ -1,10 +1,11 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; -import { X } from "lucide-react"; +import { Paperclip, X } from "lucide-react"; import { AgentAvatarContent } from "@/components/business/AgentAvatar"; import { MessageContent, MessagePreviewText } from "@/components/business/MessageContent"; import { Button } from "@/components/ui"; import { IconImage } from "@/components/ui/Icons"; import { resolveAgentAvatarFallback, type AgentLike } from "@/models/agents"; +import type { AttachmentDraft } from "@/models/attachments"; import { areComposerSegmentsEqual, getCollapsedSelectionTextOffset, @@ -38,6 +39,9 @@ import { } from "@/models/conversations"; import type { SlashPickerCandidate } from "@/models/slashCommands"; import type { ThemeMode } from "@/shared/theme/theme"; +import { AttachmentDraftStrip, MessageAttachments } from "./ConversationAttachments"; +import { ConversationMessageActions } from "./ConversationMessageActions"; +import { filesFromDataTransfer } from "./attachmentFiles"; import { MentionPicker } from "./MentionPicker"; import { MessageTimestamp } from "./MessageTime"; import { SlashPicker } from "./SlashPicker"; @@ -57,6 +61,7 @@ export type ConversationThreadPanelProps = { agents?: AgentLike[]; disabled: boolean; draftSegments: ComposerSegment[]; + attachmentDrafts?: AttachmentDraft[]; error: string; loading: boolean; locale: LocaleCode; @@ -67,8 +72,10 @@ export type ConversationThreadPanelProps = { onCloseProfilePreview?: () => void; onDismissThreadSlashPicker?: () => void; onDraftChange: (segments: ComposerSegment[]) => void; + onAddAttachments?: (files: File[]) => void; onOpenAgentDetail?: (agent: AgentLike, anchor: HTMLElement) => VoidOrPromise; onPreviewUser: (user: IMUser, anchor: HTMLElement) => void; + onRemoveAttachment?: (id: string) => void; onSend: () => VoidOrPromise; onSetThreadSlashIndex?: (index: number) => void; showToolCalls: boolean; @@ -88,6 +95,7 @@ export function ConversationThreadPanel({ loading, error, draftSegments, + attachmentDrafts = [], disabled, usersById, locale, @@ -96,6 +104,7 @@ export function ConversationThreadPanel({ t, onClose, onDraftChange, + onAddAttachments = () => {}, threadSlashCandidates = [], threadSlashIndex = 0, threadSlashPickerLoading = false, @@ -107,11 +116,13 @@ export function ConversationThreadPanel({ onCloseProfilePreview, onOpenAgentDetail, onPreviewUser, + onRemoveAttachment = () => {}, mentionableUsers = [], onSend, }: ConversationThreadPanelProps) { const threadBodyRef = useRef(null); const threadEditorRef = useRef(null); + const fileInputRef = useRef(null); const [mentionState, setMentionState] = useState(null); const [mentionIndex, setMentionIndex] = useState(0); const root = thread?.root ?? null; @@ -234,6 +245,13 @@ export function ConversationThreadPanel({ }); } + function handleFiles(files: File[]) { + if (disabled || files.length === 0) { + return; + } + onAddAttachments(files); + } + return (
- {autoCheckMessage ? ( - {autoCheckMessage} - ) : null} - {autoCheckWarning ? ( - {autoCheckWarning} - ) : null}
{!trimmedBaseURL || !trimmedAPIKey ? ( {t("modelProviderCheckRequiresConnection")} diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx index 6371c982..caafd28c 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceRows/WorkspaceRows.tsx @@ -346,10 +346,17 @@ export function WorkspaceConversationRow({ : avatarFallbackText(displayUser?.avatar, displayUser?.name, displayUser?.id); const title = isDirect && displayUser ? displayUser.name : conversation.title; const roomAvatarMembers = resolveRoomAvatarMembers(conversation, usersById, currentUserID); - const preview = isDirect ? "" : formatConversationPreview(lastMessage, conversation, currentUserID, usersById, locale, t); + const preview = isDirect + ? "" + : formatConversationPreview(lastMessage, conversation, currentUserID, usersById, locale, t); return (
) : ( -
+
{activeResourceType === "template" && selectedTemplate ? ( <>
diff --git a/web/app/src/pages/ModelProviderPage/ModelProviderPage.css b/web/app/src/pages/ModelProviderPage/ModelProviderPage.css index a65f5885..34bd2c00 100644 --- a/web/app/src/pages/ModelProviderPage/ModelProviderPage.css +++ b/web/app/src/pages/ModelProviderPage/ModelProviderPage.css @@ -16,21 +16,6 @@ overflow-y: auto; padding: 24px 32px 28px; background: var(--bg); - scrollbar-width: thin; - scrollbar-color: var(--gray-400) transparent; -} - -.model-provider-page::-webkit-scrollbar { - width: 6px; -} - -.model-provider-page::-webkit-scrollbar-track { - background: transparent; -} - -.model-provider-page::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in oklch, var(--gray-400) 70%, transparent); } .model-provider-page.empty { diff --git a/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css b/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css index 5ae74d64..723a7169 100644 --- a/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css +++ b/web/app/src/pages/TasksPage/components/TasksView/TasksView.module.css @@ -112,29 +112,6 @@ overflow-x: auto; overflow-y: hidden; padding: 8px; - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--gray-400) 58%, transparent) transparent; -} - -.tasksKanbanScroll::-webkit-scrollbar { - height: 5px; -} - -.taskBoardColumnBody::-webkit-scrollbar { - width: 5px; -} - -.tasksKanbanScroll::-webkit-scrollbar-track, -.taskBoardColumnBody::-webkit-scrollbar-track { - background: transparent; -} - -.tasksKanbanScroll::-webkit-scrollbar-thumb, -.taskBoardColumnBody::-webkit-scrollbar-thumb { - border: 1px solid transparent; - border-radius: 999px; - background: color-mix(in srgb, var(--gray-400) 62%, transparent); - background-clip: content-box; } .tasksKanban { @@ -212,8 +189,6 @@ overscroll-behavior-x: auto; padding: 4px; border-radius: var(--radius-md); - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--gray-400) 58%, transparent) transparent; } .taskBoardCard { @@ -649,21 +624,6 @@ overflow: auto; padding: 26px 30px 30px; border-right: 1px solid var(--line); - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--gray-400) 58%, transparent) transparent; -} - -.taskDetailMain::-webkit-scrollbar { - width: 5px; -} - -.taskDetailMain::-webkit-scrollbar-track { - background: transparent; -} - -.taskDetailMain::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--gray-400) 62%, transparent); } .detailBlock { @@ -1454,23 +1414,6 @@ overflow-y: auto; padding-right: 4px; border-top: 1px solid var(--line); - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--gray-400) 58%, transparent) transparent; -} - -.scheduledTaskRunList::-webkit-scrollbar { - width: 5px; -} - -.scheduledTaskRunList::-webkit-scrollbar-track { - background: transparent; -} - -.scheduledTaskRunList::-webkit-scrollbar-thumb { - border: 1px solid transparent; - border-radius: 999px; - background: color-mix(in srgb, var(--gray-400) 62%, transparent); - background-clip: content-box; } .scheduledTaskRunRow { diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx index bf81f0f5..54c1c344 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx @@ -107,9 +107,7 @@ export function AgentProfileModal({ onClose, onSave, }: AgentProfileModalProps) { - const [isEditorScrolling, setIsEditorScrolling] = useState(false); const [mcpServersInvalid, setMCPServersInvalid] = useState(false); - const editorScrollTimerRef = useRef(null); const lastTemplateIDRef = useRef(""); const createBotKind = agentModalMode === "create" ? agentCreateBotKind : undefined; const isNotificationContext = isNotificationBotDraftContext(agentDraft, editingAgent, createBotKind); @@ -261,32 +259,12 @@ export function AgentProfileModal({ onAgentModelsReset(); } - useEffect( - () => () => { - if (editorScrollTimerRef.current) { - window.clearTimeout(editorScrollTimerRef.current); - } - }, - [], - ); - useEffect(() => { if (!showMCPServers) { setMCPServersInvalid(false); } }, [showMCPServers]); - function onEditorShellScroll() { - setIsEditorScrolling(true); - if (editorScrollTimerRef.current) { - window.clearTimeout(editorScrollTimerRef.current); - } - editorScrollTimerRef.current = window.setTimeout(() => { - setIsEditorScrolling(false); - editorScrollTimerRef.current = null; - }, 700); - } - function switchCreateMode(nextMode: AgentCreateMode) { if (!isWorkerCreate || nextMode === agentCreateMode) { return; @@ -353,10 +331,7 @@ export function AgentProfileModal({
-
+
{!isNotificationContext ? (
diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx index c549491c..1f5a66c1 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/CreateModelProviderModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { ModelProviderCheckResult, ModelProviderPayload } from "@/api/modelProviders"; import { ModelProviderModelList } from "@/components/business/ProfileControls"; import { Button, Select } from "@/components/ui"; @@ -61,8 +61,6 @@ export function CreateModelProviderModal({ const [autoCheckMessage, setAutoCheckMessage] = useState(""); const [autoCheckWarning, setAutoCheckWarning] = useState(""); const [checkState, setCheckState] = useState<"idle" | "checking" | "success" | "empty" | "error">("idle"); - const [isBodyScrolling, setIsBodyScrolling] = useState(false); - const bodyScrollTimerRef = useRef(null); const trimmedName = displayName.trim(); const trimmedBaseURL = baseURL.trim(); const trimmedAPIKey = apiKey.trim(); @@ -91,15 +89,6 @@ export function CreateModelProviderModal({ setCheckState("idle"); }, [initialPresetMeta.defaultBaseURL]); - useEffect( - () => () => { - if (bodyScrollTimerRef.current) { - window.clearTimeout(bodyScrollTimerRef.current); - } - }, - [], - ); - useEffect(() => { setAutoCheckMessage(""); setAutoCheckWarning(""); @@ -189,17 +178,6 @@ export function CreateModelProviderModal({ setCheckState("idle"); } - function onBodyScroll() { - setIsBodyScrolling(true); - if (bodyScrollTimerRef.current) { - window.clearTimeout(bodyScrollTimerRef.current); - } - bodyScrollTimerRef.current = window.setTimeout(() => { - setIsBodyScrolling(false); - bodyScrollTimerRef.current = null; - }, 700); - } - const modelStatusHint = checkState === "checking" ? t("profileLoadingModels") @@ -226,7 +204,7 @@ export function CreateModelProviderModal({
-
+
{t("modelProviderCreatePresetTitle")}
diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/InviteMembersModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/InviteMembersModal.tsx index 046977c7..802624f7 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/InviteMembersModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/InviteMembersModal.tsx @@ -1,7 +1,7 @@ import { AgentAvatarContent } from "@/components/business/AgentAvatar"; import { Button as CSGButton } from "@/components/ui/Button"; import { TrashIcon } from "@/components/ui/Icons"; -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { IMUser, TranslateFn } from "@/models/conversations"; import { toggleSelection } from "@/shared/lib/collections"; @@ -72,8 +72,6 @@ export function InviteMembersModal({ onInvite, onPreviewUser, }: InviteMembersModalProps) { - const [isScrolling, setIsScrolling] = useState(false); - const scrollTimerRef = useRef(null); const candidateIDs = candidates.map((user) => user.id).filter(Boolean); const allCandidatesSelected = candidateIDs.length > 0 && candidateIDs.every((id) => inviteUserIDs.includes(id)); const selectedMemberCount = candidateIDs.filter((id) => inviteUserIDs.includes(id)).length; @@ -88,23 +86,9 @@ export function InviteMembersModal({ window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("keydown", onKeyDown); - if (scrollTimerRef.current) { - window.clearTimeout(scrollTimerRef.current); - } }; }, [onClose]); - function onScrollContent() { - setIsScrolling(true); - if (scrollTimerRef.current) { - window.clearTimeout(scrollTimerRef.current); - } - scrollTimerRef.current = window.setTimeout(() => { - setIsScrolling(false); - scrollTimerRef.current = null; - }, 700); - } - return (
-
+
{t("currentMembers")}
diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/WorkspaceModals.css b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/WorkspaceModals.css index 4522ffcb..f7517441 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/WorkspaceModals.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/WorkspaceModals.css @@ -82,30 +82,6 @@ padding: 16px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -.create-model-provider-body::-webkit-scrollbar { - width: 8px; - height: 0; -} - -.create-model-provider-body::-webkit-scrollbar-track { - background: transparent; -} - -.create-model-provider-body::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 6px; -} - -.create-model-provider-body.is-scrolling { - scrollbar-color: var(--gray-400) transparent; -} - -.create-model-provider-body.is-scrolling::-webkit-scrollbar-thumb { - background-color: var(--gray-400); } .create-model-provider-body .field { @@ -660,29 +636,6 @@ min-height: 0; padding: 0; overflow: auto; - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -.invite-members-content::-webkit-scrollbar { - width: 8px; -} - -.invite-members-content::-webkit-scrollbar-track { - background: transparent; -} - -.invite-members-content::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: var(--radius-base); -} - -.invite-members-content.is-scrolling { - scrollbar-color: var(--gray-400) transparent; -} - -.invite-members-content.is-scrolling::-webkit-scrollbar-thumb { - background-color: var(--gray-400); } .invite-members-content .field { @@ -1769,30 +1722,6 @@ padding: 16px 0; overflow-x: hidden; overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: transparent transparent; -} - -.profile-modal.agent-modal .profile-editor-shell::-webkit-scrollbar { - width: 8px; - height: 0; -} - -.profile-modal.agent-modal .profile-editor-shell::-webkit-scrollbar-track { - background: transparent; -} - -.profile-modal.agent-modal .profile-editor-shell::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 6px; -} - -.profile-modal.agent-modal .profile-editor-shell.is-scrolling { - scrollbar-color: var(--gray-400) transparent; -} - -.profile-modal.agent-modal .profile-editor-shell.is-scrolling::-webkit-scrollbar-thumb { - background-color: var(--gray-400); } .profile-modal.agent-modal .profile-section { @@ -2362,24 +2291,6 @@ max-height: 136px; overflow-y: auto; padding-right: 4px; - scrollbar-width: thin; - scrollbar-color: var(--gray-400) transparent; -} - -.modal-card .profile-advanced-grid .env-editor::-webkit-scrollbar, -.profile-section .profile-advanced-grid .env-editor::-webkit-scrollbar { - width: 6px; -} - -.modal-card .profile-advanced-grid .env-editor::-webkit-scrollbar-track, -.profile-section .profile-advanced-grid .env-editor::-webkit-scrollbar-track { - background: transparent; -} - -.modal-card .profile-advanced-grid .env-editor::-webkit-scrollbar-thumb, -.profile-section .profile-advanced-grid .env-editor::-webkit-scrollbar-thumb { - border-radius: var(--radius-full); - background: color-mix(in oklch, var(--gray-400) 70%, transparent); } .modal-card .profile-advanced-grid .env-row, diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.module.css b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.module.css index 685967b3..2257a26d 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.module.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.module.css @@ -584,35 +584,6 @@ scrollbar-gutter: stable; } -.contextNav::-webkit-scrollbar { - width: 6px; -} - -.contextNav::-webkit-scrollbar-track { - background: transparent; -} - -.contextNav::-webkit-scrollbar-thumb { - border-radius: 999px; - background: transparent; - transition: background-color 160ms ease; -} - -.contextNav.scrollbarScrolling::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--muted) 44%, transparent); -} - -@supports not selector(::-webkit-scrollbar) { - .contextNav { - scrollbar-color: transparent transparent; - scrollbar-width: thin; - } - - .contextNav.scrollbarScrolling { - scrollbar-color: color-mix(in srgb, var(--muted) 58%, transparent) transparent; - } -} - :global(:root[data-theme="dark"]) .slot, :global(:root[data-theme="dark"]) .primarySidebar, :global(:root[data-theme="dark"]) .mainColumn, diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 009637aa..090deb70 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -29,8 +29,6 @@ import type { PrimaryNavigationItem, PrimaryNavigationSection } from "./Workspac import type { WorkspaceContextSectionId, WorkspaceSidebarProps } from "./types"; type SidebarNavigationIcon = ComponentType<{ size?: number | string }>; -const SCROLLBAR_HIDE_DELAY_MS = 700; - const WORKSPACE_NAVIGATION_ICONS = { agents: SidebarRobotIcon, computers: SidebarLaptopIcon, @@ -123,10 +121,8 @@ export function WorkspaceSidebar({ startingTaskID = "", }: WorkspaceSidebarProps) { const [contextQuery, setContextQuery] = useState(""); - const [isContextNavScrolling, setIsContextNavScrolling] = useState(false); const [skillUploadOpen, setSkillUploadOpen] = useState(false); const contextNavRef = useRef(null); - const contextNavScrollTimerRef = useRef(null); const currentUser = usersById.get(currentUserID); const hasContextSidebar = workspaceHasContextSidebar(activePane); const firstWorkerAgent = workerAgentItems[0] ?? agentItems[0] ?? null; @@ -159,15 +155,6 @@ export function WorkspaceSidebar({ } }, [routeContextSectionId]); - useEffect( - () => () => { - if (contextNavScrollTimerRef.current !== null) { - window.clearTimeout(contextNavScrollTimerRef.current); - } - }, - [], - ); - const primaryNavigationSections = useMemo( () => [ { @@ -407,17 +394,6 @@ export function WorkspaceSidebar({ scheduleSectionScroll(() => contextNavRef.current, item.groupId); } - function handleContextNavScroll() { - setIsContextNavScrolling(true); - if (contextNavScrollTimerRef.current !== null) { - window.clearTimeout(contextNavScrollTimerRef.current); - } - contextNavScrollTimerRef.current = window.setTimeout(() => { - setIsContextNavScrolling(false); - contextNavScrollTimerRef.current = null; - }, SCROLLBAR_HIDE_DELAY_MS); - } - return (
-