diff --git a/.env.example b/.env.example index 1c0c93ab..e627d3f9 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,15 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +# S3 / CloudFront +S3_BUCKET= +S3_REGION=us-west-2 +CLOUDFRONT_KEY_PAIR_ID= +CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key +CLOUDFRONT_PRIVATE_KEY= +CLOUDFRONT_DOMAIN= +COOKIE_DOMAIN= + # Frontend FRONTEND_PORT=3000 FRONTEND_ORIGIN=http://localhost:3000 diff --git a/.eslintcache b/.eslintcache new file mode 100644 index 00000000..b8173419 --- /dev/null +++ b/.eslintcache @@ -0,0 +1 @@ +[{"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ChatView.tsx":"1","/Users/bohan/Desktop/IndexLabs/multica/electron.vite.config.ts":"2","/Users/bohan/Desktop/IndexLabs/multica/eslint.config.mjs":"3","/Users/bohan/Desktop/IndexLabs/multica/src/main/cli.ts":"4","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AgentProcess.ts":"5","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/Conductor.ts":"6","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/index.ts":"7","/Users/bohan/Desktop/IndexLabs/multica/src/main/config/defaults.ts":"8","/Users/bohan/Desktop/IndexLabs/multica/src/main/config/index.ts":"9","/Users/bohan/Desktop/IndexLabs/multica/src/main/index.ts":"10","/Users/bohan/Desktop/IndexLabs/multica/src/main/ipc/handlers.ts":"11","/Users/bohan/Desktop/IndexLabs/multica/src/main/session/SessionStore.ts":"12","/Users/bohan/Desktop/IndexLabs/multica/src/main/session/index.ts":"13","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-check.ts":"14","/Users/bohan/Desktop/IndexLabs/multica/src/preload/index.d.ts":"15","/Users/bohan/Desktop/IndexLabs/multica/src/preload/index.ts":"16","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/App.tsx":"17","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/AppSidebar.tsx":"18","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/MessageInput.tsx":"19","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Modals.tsx":"20","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Settings.tsx":"21","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/StatusBar.tsx":"22","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ToolCallItem.tsx":"23","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Versions.tsx":"24","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/index.ts":"25","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/badge.tsx":"26","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/button.tsx":"27","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/card.tsx":"28","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/collapsible.tsx":"29","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/dialog.tsx":"30","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/input.tsx":"31","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/separator.tsx":"32","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sheet.tsx":"33","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sidebar.tsx":"34","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/skeleton.tsx":"35","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/textarea.tsx":"36","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/toggle-group.tsx":"37","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/toggle.tsx":"38","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/tooltip.tsx":"39","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/contexts/ThemeContext.tsx":"40","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/env.d.ts":"41","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/use-mobile.ts":"42","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useApp.ts":"43","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/lib/utils.ts":"44","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/main.tsx":"45","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/modalStore.ts":"46","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/permissionStore.ts":"47","/Users/bohan/Desktop/IndexLabs/multica/src/shared/electron-api.d.ts":"48","/Users/bohan/Desktop/IndexLabs/multica/src/shared/ipc-channels.ts":"49","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/session.ts":"50","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types.ts":"51","/Users/bohan/Desktop/IndexLabs/multica/vite.config.ts":"52","/Users/bohan/Desktop/IndexLabs/multica/scripts/check-tests.ts":"53","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AcpClientFactory.ts":"54","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/historyReplay.ts":"55","/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/AskUserQuestionHandler.ts":"56","/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/PermissionManager.ts":"57","/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/QuestionToolWorkaround.ts":"58","/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/index.ts":"59","/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/types.ts":"60","/Users/bohan/Desktop/IndexLabs/multica/src/main/updater/index.ts":"61","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-install.ts":"62","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/path.ts":"63","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/FileIcons.tsx":"64","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/FileTree.tsx":"65","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/UpdateNotification.tsx":"66","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/layout/RightPanel.tsx":"67","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/layout/index.ts":"68","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/AskUserQuestionUI.tsx":"69","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/CompletedAnswer.tsx":"70","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/CustomInput.tsx":"71","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/QuestionOptions.tsx":"72","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/QuestionProgress.tsx":"73","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/index.ts":"74","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/PermissionRequestItem.tsx":"75","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/StandardPermissionUI.tsx":"76","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/index.ts":"77","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/types.ts":"78","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/context-menu.tsx":"79","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/dropdown-menu.tsx":"80","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sonner.tsx":"81","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useResize.ts":"82","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/fileChangeStore.ts":"83","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/uiStore.ts":"84","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/error.ts":"85","/Users/bohan/Desktop/IndexLabs/multica/src/shared/tool-names.ts":"86","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/message.ts":"87","/Users/bohan/Desktop/IndexLabs/multica/tests/integration/conductor/Conductor.test.ts":"88","/Users/bohan/Desktop/IndexLabs/multica/tests/integration/ipc/handlers.test.ts":"89","/Users/bohan/Desktop/IndexLabs/multica/tests/setup/mocks/acp-sdk.ts":"90","/Users/bohan/Desktop/IndexLabs/multica/tests/setup/mocks/electron.ts":"91","/Users/bohan/Desktop/IndexLabs/multica/tests/setup/vitest.setup.ts":"92","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/session/SessionStore.test.ts":"93","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-check.test.ts":"94","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-install.test.ts":"95","/Users/bohan/Desktop/IndexLabs/multica/vitest.config.ts":"96","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AgentProcessManager.ts":"97","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/G3Workaround.ts":"98","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/PromptHandler.ts":"99","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/SessionLifecycle.ts":"100","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/types.ts":"101","/Users/bohan/Desktop/IndexLabs/multica/src/main/logger.ts":"102","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/git.ts":"103","/Users/bohan/Desktop/IndexLabs/multica/src/main/watcher/FileWatcher.ts":"104","/Users/bohan/Desktop/IndexLabs/multica/src/main/watcher/index.ts":"105","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/AgentModelSelector.tsx":"106","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ModeSelector.tsx":"107","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/SlashCommandMenu.tsx":"108","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/CompletedMessageFooter.tsx":"109","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/LoadingIndicator.tsx":"110","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/Spinner.tsx":"111","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useChatScroll.ts":"112","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/commandStore.ts":"113","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/draftStore.ts":"114","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/fileTree.ts":"115","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/slashCommand.ts":"116","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/mode.ts":"117","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/model.ts":"118","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/AgentProcessManager.test.ts":"119","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/G3Workaround.test.ts":"120","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/PromptHandler.test.ts":"121","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/SessionLifecycle.test.ts":"122","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/git.test.ts":"123","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/FileTree.test.ts":"124","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/MessageInput.test.tsx":"125","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/SlashCommandMenu.test.ts":"126","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/hooks/useApp.test.tsx":"127","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/lib/utils.test.ts":"128","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/commandStore.test.ts":"129","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/draftStore.test.ts":"130","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/uiStore.test.ts":"131","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-version.ts":"132","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/permission/PermissionManager.test.ts":"133","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-version.test.ts":"134","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/Settings.test.tsx":"135","/Users/bohan/Desktop/IndexLabs/multica/src/main/session/DatabaseStore.ts":"136","/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/pathValidation.ts":"137","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ProjectItem.tsx":"138","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/CodeBlock.tsx":"139","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/Markdown.tsx":"140","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/StreamingMarkdown.tsx":"141","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/index.ts":"142","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/linkify.ts":"143","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/LiveTimer.tsx":"144","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/path.ts":"145","/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/project.ts":"146","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/pathValidation.test.ts":"147","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/watcher/FileWatcher.test.ts":"148","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/FileTreeComponent.test.tsx":"149","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/utils/path.test.ts":"150","/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/TitleGenerator.ts":"151","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/PlanApprovalUI.tsx":"152","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/TitleGenerator.test.ts":"153","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/ipc/handlers.test.ts":"154","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/ProjectItem.test.tsx":"155","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/HighlightedText.tsx":"156","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/TextSearchBar.tsx":"157","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useTextSearch.ts":"158","/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/messageGrouping.ts":"159","/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/hooks/useTextSearch.test.tsx":"160"},{"size":44062,"mtime":1769797388255,"results":"161","hashOfConfig":"162"},{"size":880,"mtime":1768551869189,"results":"163","hashOfConfig":"162"},{"size":1733,"mtime":1768539326005,"results":"164","hashOfConfig":"165"},{"size":23842,"mtime":1769154246015,"results":"166","hashOfConfig":"162"},{"size":3836,"mtime":1768539326015,"results":"167","hashOfConfig":"162"},{"size":12702,"mtime":1769154246016,"results":"168","hashOfConfig":"162"},{"size":839,"mtime":1769402788431,"results":"169","hashOfConfig":"162"},{"size":939,"mtime":1768539326016,"results":"170","hashOfConfig":"162"},{"size":60,"mtime":1768328070957,"results":"171","hashOfConfig":"162"},{"size":6324,"mtime":1769402788431,"results":"172","hashOfConfig":"162"},{"size":21708,"mtime":1769402788432,"results":"173","hashOfConfig":"162"},{"size":12224,"mtime":1769402788432,"results":"174","hashOfConfig":"162"},{"size":46,"mtime":1768331741123,"results":"175","hashOfConfig":"162"},{"size":3714,"mtime":1769146118330,"results":"176","hashOfConfig":"162"},{"size":144,"mtime":1768328070957,"results":"177","hashOfConfig":"162"},{"size":9263,"mtime":1769154246018,"results":"178","hashOfConfig":"162"},{"size":14498,"mtime":1769797275052,"results":"179","hashOfConfig":"162"},{"size":5135,"mtime":1769402788433,"results":"180","hashOfConfig":"162"},{"size":19036,"mtime":1769402788433,"results":"181","hashOfConfig":"162"},{"size":12745,"mtime":1769154246020,"results":"182","hashOfConfig":"162"},{"size":21930,"mtime":1769154246020,"results":"183","hashOfConfig":"162"},{"size":1205,"mtime":1769154246021,"results":"184","hashOfConfig":"162"},{"size":13410,"mtime":1769402788434,"results":"185","hashOfConfig":"162"},{"size":428,"mtime":1768328070958,"results":"186","hashOfConfig":"162"},{"size":362,"mtime":1769766998689,"results":"187","hashOfConfig":"162"},{"size":1661,"mtime":1769056174083,"results":"188","hashOfConfig":"162"},{"size":2306,"mtime":1769056174083,"results":"189","hashOfConfig":"162"},{"size":2049,"mtime":1769056174084,"results":"190","hashOfConfig":"162"},{"size":805,"mtime":1769056174084,"results":"191","hashOfConfig":"162"},{"size":4146,"mtime":1769056174084,"results":"192","hashOfConfig":"162"},{"size":968,"mtime":1769154246021,"results":"193","hashOfConfig":"162"},{"size":704,"mtime":1769056174084,"results":"194","hashOfConfig":"162"},{"size":4279,"mtime":1769056174084,"results":"195","hashOfConfig":"162"},{"size":22848,"mtime":1769402788435,"results":"196","hashOfConfig":"162"},{"size":295,"mtime":1769056174085,"results":"197","hashOfConfig":"162"},{"size":765,"mtime":1769154246022,"results":"198","hashOfConfig":"162"},{"size":2350,"mtime":1769056174085,"results":"199","hashOfConfig":"162"},{"size":1635,"mtime":1769056174085,"results":"200","hashOfConfig":"162"},{"size":1958,"mtime":1769056174085,"results":"201","hashOfConfig":"162"},{"size":2217,"mtime":1769056174086,"results":"202","hashOfConfig":"162"},{"size":38,"mtime":1768328070958,"results":"203","hashOfConfig":"162"},{"size":600,"mtime":1769056174086,"results":"204","hashOfConfig":"162"},{"size":32927,"mtime":1769421512873,"results":"205","hashOfConfig":"162"},{"size":1318,"mtime":1769154246022,"results":"206","hashOfConfig":"162"},{"size":413,"mtime":1769056174086,"results":"207","hashOfConfig":"162"},{"size":2140,"mtime":1769154246022,"results":"208","hashOfConfig":"162"},{"size":8125,"mtime":1768539326030,"results":"209","hashOfConfig":"162"},{"size":8344,"mtime":1769154246023,"results":"210","hashOfConfig":"162"},{"size":3184,"mtime":1769154246023,"results":"211","hashOfConfig":"162"},{"size":2843,"mtime":1769154246023,"results":"212","hashOfConfig":"162"},{"size":2142,"mtime":1769154246023,"results":"213","hashOfConfig":"162"},{"size":452,"mtime":1768378385119,"results":"214","hashOfConfig":"162"},{"size":3969,"mtime":1768539326014,"results":"215","hashOfConfig":"162"},{"size":5350,"mtime":1769056174076,"results":"216","hashOfConfig":"162"},{"size":7674,"mtime":1768539326016,"results":"217","hashOfConfig":"162"},{"size":4575,"mtime":1768539326016,"results":"218","hashOfConfig":"162"},{"size":7828,"mtime":1769146118330,"results":"219","hashOfConfig":"162"},{"size":3984,"mtime":1768539326017,"results":"220","hashOfConfig":"162"},{"size":249,"mtime":1768481904098,"results":"221","hashOfConfig":"162"},{"size":1517,"mtime":1768481904098,"results":"222","hashOfConfig":"162"},{"size":3458,"mtime":1768551857476,"results":"223","hashOfConfig":"162"},{"size":5884,"mtime":1769146118330,"results":"224","hashOfConfig":"162"},{"size":664,"mtime":1769056174078,"results":"225","hashOfConfig":"162"},{"size":6054,"mtime":1769056174079,"results":"226","hashOfConfig":"162"},{"size":20212,"mtime":1769154246019,"results":"227","hashOfConfig":"162"},{"size":4108,"mtime":1769056174081,"results":"228","hashOfConfig":"162"},{"size":4123,"mtime":1769056174082,"results":"229","hashOfConfig":"162"},{"size":144,"mtime":1768539326021,"results":"230","hashOfConfig":"162"},{"size":5932,"mtime":1769146118331,"results":"231","hashOfConfig":"162"},{"size":1208,"mtime":1769056174082,"results":"232","hashOfConfig":"162"},{"size":1084,"mtime":1769056174082,"results":"233","hashOfConfig":"162"},{"size":1869,"mtime":1769146118332,"results":"234","hashOfConfig":"162"},{"size":944,"mtime":1769056174083,"results":"235","hashOfConfig":"162"},{"size":303,"mtime":1768481904100,"results":"236","hashOfConfig":"162"},{"size":3450,"mtime":1769402788434,"results":"237","hashOfConfig":"162"},{"size":3748,"mtime":1769146118332,"results":"238","hashOfConfig":"162"},{"size":224,"mtime":1768481904100,"results":"239","hashOfConfig":"162"},{"size":1727,"mtime":1769402788435,"results":"240","hashOfConfig":"162"},{"size":8440,"mtime":1769056174084,"results":"241","hashOfConfig":"162"},{"size":5258,"mtime":1769056174084,"results":"242","hashOfConfig":"162"},{"size":1739,"mtime":1769056174085,"results":"243","hashOfConfig":"162"},{"size":2444,"mtime":1769056174086,"results":"244","hashOfConfig":"162"},{"size":1029,"mtime":1769056174087,"results":"245","hashOfConfig":"162"},{"size":2061,"mtime":1769056174087,"results":"246","hashOfConfig":"162"},{"size":676,"mtime":1768472046970,"results":"247","hashOfConfig":"162"},{"size":2319,"mtime":1769402788437,"results":"248","hashOfConfig":"162"},{"size":416,"mtime":1768455856951,"results":"249","hashOfConfig":"162"},{"size":7084,"mtime":1769056174088,"results":"250","hashOfConfig":"162"},{"size":16493,"mtime":1769402788438,"results":"251","hashOfConfig":"162"},{"size":721,"mtime":1769056174089,"results":"252","hashOfConfig":"162"},{"size":411,"mtime":1768539326032,"results":"253","hashOfConfig":"162"},{"size":914,"mtime":1769154246023,"results":"254","hashOfConfig":"162"},{"size":9521,"mtime":1769154246024,"results":"255","hashOfConfig":"162"},{"size":5178,"mtime":1769146118333,"results":"256","hashOfConfig":"162"},{"size":9877,"mtime":1769056174091,"results":"257","hashOfConfig":"162"},{"size":667,"mtime":1769056174103,"results":"258","hashOfConfig":"162"},{"size":8032,"mtime":1769056174076,"results":"259","hashOfConfig":"162"},{"size":3277,"mtime":1769056174076,"results":"260","hashOfConfig":"162"},{"size":12480,"mtime":1769156395862,"results":"261","hashOfConfig":"162"},{"size":12140,"mtime":1769154246016,"results":"262","hashOfConfig":"162"},{"size":11285,"mtime":1769154246016,"results":"263","hashOfConfig":"162"},{"size":482,"mtime":1769056174078,"results":"264","hashOfConfig":"162"},{"size":1853,"mtime":1769154246017,"results":"265","hashOfConfig":"162"},{"size":12237,"mtime":1769154246018,"results":"266","hashOfConfig":"162"},{"size":44,"mtime":1769056174078,"results":"267","hashOfConfig":"162"},{"size":16091,"mtime":1769056174079,"results":"268","hashOfConfig":"162"},{"size":3849,"mtime":1769056174080,"results":"269","hashOfConfig":"162"},{"size":4376,"mtime":1769056174081,"results":"270","hashOfConfig":"162"},{"size":3811,"mtime":1769056174083,"results":"271","hashOfConfig":"162"},{"size":8980,"mtime":1769154246021,"results":"272","hashOfConfig":"162"},{"size":1274,"mtime":1769056174083,"results":"273","hashOfConfig":"162"},{"size":6879,"mtime":1769056174086,"results":"274","hashOfConfig":"162"},{"size":900,"mtime":1769056174086,"results":"275","hashOfConfig":"162"},{"size":1700,"mtime":1769056174087,"results":"276","hashOfConfig":"162"},{"size":316,"mtime":1769056174087,"results":"277","hashOfConfig":"162"},{"size":1370,"mtime":1769402788437,"results":"278","hashOfConfig":"162"},{"size":159,"mtime":1769056174088,"results":"279","hashOfConfig":"162"},{"size":153,"mtime":1769056174088,"results":"280","hashOfConfig":"162"},{"size":11304,"mtime":1769056174089,"results":"281","hashOfConfig":"162"},{"size":4831,"mtime":1769056174089,"results":"282","hashOfConfig":"162"},{"size":21716,"mtime":1769156395863,"results":"283","hashOfConfig":"162"},{"size":15131,"mtime":1769154246024,"results":"284","hashOfConfig":"162"},{"size":5794,"mtime":1769154246024,"results":"285","hashOfConfig":"162"},{"size":3065,"mtime":1769056174091,"results":"286","hashOfConfig":"162"},{"size":3831,"mtime":1769056174091,"results":"287","hashOfConfig":"162"},{"size":6453,"mtime":1769402788440,"results":"288","hashOfConfig":"162"},{"size":20168,"mtime":1769421552072,"results":"289","hashOfConfig":"162"},{"size":2155,"mtime":1769154246025,"results":"290","hashOfConfig":"162"},{"size":1897,"mtime":1769056174092,"results":"291","hashOfConfig":"162"},{"size":1785,"mtime":1769056174092,"results":"292","hashOfConfig":"162"},{"size":3464,"mtime":1769056174092,"results":"293","hashOfConfig":"162"},{"size":8251,"mtime":1769146118330,"results":"294","hashOfConfig":"162"},{"size":7362,"mtime":1769146118333,"results":"295","hashOfConfig":"162"},{"size":3283,"mtime":1769146118333,"results":"296","hashOfConfig":"162"},{"size":7802,"mtime":1769154246025,"results":"297","hashOfConfig":"162"},{"size":23030,"mtime":1769154246017,"results":"298","hashOfConfig":"162"},{"size":482,"mtime":1769157547774,"results":"299","hashOfConfig":"162"},{"size":13876,"mtime":1769402788434,"results":"300","hashOfConfig":"162"},{"size":8224,"mtime":1769154246021,"results":"301","hashOfConfig":"162"},{"size":22492,"mtime":1770015592124,"results":"302","hashOfConfig":"162"},{"size":5110,"mtime":1769154246021,"results":"303","hashOfConfig":"162"},{"size":318,"mtime":1769154246021,"results":"304","hashOfConfig":"162"},{"size":5765,"mtime":1769154246021,"results":"305","hashOfConfig":"162"},{"size":4343,"mtime":1769154246021,"results":"306","hashOfConfig":"162"},{"size":301,"mtime":1769154246022,"results":"307","hashOfConfig":"162"},{"size":1311,"mtime":1769154246023,"results":"308","hashOfConfig":"162"},{"size":583,"mtime":1769402788439,"results":"309","hashOfConfig":"162"},{"size":3460,"mtime":1769154246024,"results":"310","hashOfConfig":"162"},{"size":1685,"mtime":1769157547774,"results":"311","hashOfConfig":"162"},{"size":778,"mtime":1769154246025,"results":"312","hashOfConfig":"162"},{"size":5930,"mtime":1769402788431,"results":"313","hashOfConfig":"162"},{"size":3402,"mtime":1769402788434,"results":"314","hashOfConfig":"162"},{"size":9143,"mtime":1769402788438,"results":"315","hashOfConfig":"162"},{"size":3864,"mtime":1769402788438,"results":"316","hashOfConfig":"162"},{"size":6307,"mtime":1769402788439,"results":"317","hashOfConfig":"162"},{"size":2340,"mtime":1769767315053,"results":"318","hashOfConfig":"162"},{"size":4076,"mtime":1769767315247,"results":"319","hashOfConfig":"162"},{"size":8190,"mtime":1769797187156,"results":"320","hashOfConfig":"162"},{"size":5291,"mtime":1770015573370,"results":"321","hashOfConfig":"162"},{"size":15928,"mtime":1769780584114,"results":"322","hashOfConfig":"162"},{"filePath":"323","messages":"324","suppressedMessages":"325","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1dj5kob",{"filePath":"326","messages":"327","suppressedMessages":"328","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"329","messages":"330","suppressedMessages":"331","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1wlpi0g",{"filePath":"332","messages":"333","suppressedMessages":"334","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"335","messages":"336","suppressedMessages":"337","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"338","messages":"339","suppressedMessages":"340","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"341","messages":"342","suppressedMessages":"343","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"344","messages":"345","suppressedMessages":"346","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"347","messages":"348","suppressedMessages":"349","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"350","messages":"351","suppressedMessages":"352","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"353","messages":"354","suppressedMessages":"355","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"356","messages":"357","suppressedMessages":"358","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"359","messages":"360","suppressedMessages":"361","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"362","messages":"363","suppressedMessages":"364","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"365","messages":"366","suppressedMessages":"367","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"368","messages":"369","suppressedMessages":"370","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"371","messages":"372","suppressedMessages":"373","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"374","messages":"375","suppressedMessages":"376","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"377","messages":"378","suppressedMessages":"379","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"380","messages":"381","suppressedMessages":"382","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"383","messages":"384","suppressedMessages":"385","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"386","messages":"387","suppressedMessages":"388","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"389","messages":"390","suppressedMessages":"391","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"392","messages":"393","suppressedMessages":"394","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"395","messages":"396","suppressedMessages":"397","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"398","messages":"399","suppressedMessages":"400","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"401","messages":"402","suppressedMessages":"403","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"404","messages":"405","suppressedMessages":"406","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"407","messages":"408","suppressedMessages":"409","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"410","messages":"411","suppressedMessages":"412","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"413","messages":"414","suppressedMessages":"415","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"416","messages":"417","suppressedMessages":"418","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"419","messages":"420","suppressedMessages":"421","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"422","messages":"423","suppressedMessages":"424","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"425","messages":"426","suppressedMessages":"427","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"428","messages":"429","suppressedMessages":"430","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"431","messages":"432","suppressedMessages":"433","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"434","messages":"435","suppressedMessages":"436","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"437","messages":"438","suppressedMessages":"439","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"440","messages":"441","suppressedMessages":"442","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"443","messages":"444","suppressedMessages":"445","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"446","messages":"447","suppressedMessages":"448","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"449","messages":"450","suppressedMessages":"451","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"452","messages":"453","suppressedMessages":"454","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"455","messages":"456","suppressedMessages":"457","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"458","messages":"459","suppressedMessages":"460","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"461","messages":"462","suppressedMessages":"463","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"464","messages":"465","suppressedMessages":"466","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"467","messages":"468","suppressedMessages":"469","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"470","messages":"471","suppressedMessages":"472","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"473","messages":"474","suppressedMessages":"475","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"476","messages":"477","suppressedMessages":"478","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"479","messages":"480","suppressedMessages":"481","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"482","messages":"483","suppressedMessages":"484","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"485","messages":"486","suppressedMessages":"487","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"488","messages":"489","suppressedMessages":"490","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"491","messages":"492","suppressedMessages":"493","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"494","messages":"495","suppressedMessages":"496","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"497","messages":"498","suppressedMessages":"499","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"500","messages":"501","suppressedMessages":"502","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"503","messages":"504","suppressedMessages":"505","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"506","messages":"507","suppressedMessages":"508","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"509","messages":"510","suppressedMessages":"511","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"512","messages":"513","suppressedMessages":"514","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"515","messages":"516","suppressedMessages":"517","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"518","messages":"519","suppressedMessages":"520","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"521","messages":"522","suppressedMessages":"523","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"524","messages":"525","suppressedMessages":"526","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"527","messages":"528","suppressedMessages":"529","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"530","messages":"531","suppressedMessages":"532","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"533","messages":"534","suppressedMessages":"535","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"536","messages":"537","suppressedMessages":"538","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"539","messages":"540","suppressedMessages":"541","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"542","messages":"543","suppressedMessages":"544","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"545","messages":"546","suppressedMessages":"547","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"548","messages":"549","suppressedMessages":"550","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"551","messages":"552","suppressedMessages":"553","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"554","messages":"555","suppressedMessages":"556","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"557","messages":"558","suppressedMessages":"559","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"560","messages":"561","suppressedMessages":"562","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"563","messages":"564","suppressedMessages":"565","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"566","messages":"567","suppressedMessages":"568","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"569","messages":"570","suppressedMessages":"571","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"572","messages":"573","suppressedMessages":"574","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"575","messages":"576","suppressedMessages":"577","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"578","messages":"579","suppressedMessages":"580","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"581","messages":"582","suppressedMessages":"583","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"584","messages":"585","suppressedMessages":"586","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"587","messages":"588","suppressedMessages":"589","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"590","messages":"591","suppressedMessages":"592","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"593","messages":"594","suppressedMessages":"595","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"596","messages":"597","suppressedMessages":"598","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"599","messages":"600","suppressedMessages":"601","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"602","messages":"603","suppressedMessages":"604","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"605","messages":"606","suppressedMessages":"607","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"608","messages":"609","suppressedMessages":"610","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"611","messages":"612","suppressedMessages":"613","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"614","messages":"615","suppressedMessages":"616","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"617","messages":"618","suppressedMessages":"619","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"620","messages":"621","suppressedMessages":"622","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"623","messages":"624","suppressedMessages":"625","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"626","messages":"627","suppressedMessages":"628","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"629","messages":"630","suppressedMessages":"631","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"632","messages":"633","suppressedMessages":"634","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"635","messages":"636","suppressedMessages":"637","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"638","messages":"639","suppressedMessages":"640","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"641","messages":"642","suppressedMessages":"643","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"644","messages":"645","suppressedMessages":"646","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"647","messages":"648","suppressedMessages":"649","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"650","messages":"651","suppressedMessages":"652","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"653","messages":"654","suppressedMessages":"655","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"656","messages":"657","suppressedMessages":"658","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"659","messages":"660","suppressedMessages":"661","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"662","messages":"663","suppressedMessages":"664","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"665","messages":"666","suppressedMessages":"667","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"668","messages":"669","suppressedMessages":"670","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"671","messages":"672","suppressedMessages":"673","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"674","messages":"675","suppressedMessages":"676","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"677","messages":"678","suppressedMessages":"679","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"680","messages":"681","suppressedMessages":"682","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"683","messages":"684","suppressedMessages":"685","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"686","messages":"687","suppressedMessages":"688","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"689","messages":"690","suppressedMessages":"691","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"692","messages":"693","suppressedMessages":"694","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"695","messages":"696","suppressedMessages":"697","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"698","messages":"699","suppressedMessages":"700","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"701","messages":"702","suppressedMessages":"703","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"704","messages":"705","suppressedMessages":"706","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"707","messages":"708","suppressedMessages":"709","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"710","messages":"711","suppressedMessages":"712","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"713","messages":"714","suppressedMessages":"715","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"716","messages":"717","suppressedMessages":"718","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"719","messages":"720","suppressedMessages":"721","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"722","messages":"723","suppressedMessages":"724","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"725","messages":"726","suppressedMessages":"727","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"728","messages":"729","suppressedMessages":"730","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"731","messages":"732","suppressedMessages":"733","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"734","messages":"735","suppressedMessages":"736","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"737","messages":"738","suppressedMessages":"739","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"740","messages":"741","suppressedMessages":"742","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"743","messages":"744","suppressedMessages":"745","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"746","messages":"747","suppressedMessages":"748","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"749","messages":"750","suppressedMessages":"751","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"752","messages":"753","suppressedMessages":"754","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"755","messages":"756","suppressedMessages":"757","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"758","messages":"759","suppressedMessages":"760","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"761","messages":"762","suppressedMessages":"763","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"764","messages":"765","suppressedMessages":"766","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"767","messages":"768","suppressedMessages":"769","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"770","messages":"771","suppressedMessages":"772","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"773","messages":"774","suppressedMessages":"775","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"776","messages":"777","suppressedMessages":"778","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"779","messages":"780","suppressedMessages":"781","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"782","messages":"783","suppressedMessages":"784","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"785","messages":"786","suppressedMessages":"787","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"788","messages":"789","suppressedMessages":"790","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"791","messages":"792","suppressedMessages":"793","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"794","messages":"795","suppressedMessages":"796","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"797","messages":"798","suppressedMessages":"799","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"800","messages":"801","suppressedMessages":"802","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ChatView.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/electron.vite.config.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/eslint.config.mjs",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/cli.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AgentProcess.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/Conductor.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/config/defaults.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/config/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/ipc/handlers.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/session/SessionStore.ts",[],["803"],"/Users/bohan/Desktop/IndexLabs/multica/src/main/session/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-check.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/preload/index.d.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/preload/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/App.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/AppSidebar.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/MessageInput.tsx",[],["804"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Modals.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Settings.tsx",[],["805","806"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/StatusBar.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ToolCallItem.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Versions.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/badge.tsx",[],["807"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/button.tsx",[],["808"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/card.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/collapsible.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/dialog.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/input.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/separator.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sheet.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sidebar.tsx",[],["809","810"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/skeleton.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/textarea.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/toggle-group.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/toggle.tsx",[],["811"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/tooltip.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/contexts/ThemeContext.tsx",[],["812"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/env.d.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/use-mobile.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useApp.ts",[],["813","814","815"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/lib/utils.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/main.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/modalStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/permissionStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/electron-api.d.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/ipc-channels.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/session.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/vite.config.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/scripts/check-tests.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AcpClientFactory.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/historyReplay.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/AskUserQuestionHandler.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/PermissionManager.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/QuestionToolWorkaround.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/permission/types.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/updater/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-install.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/path.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/FileIcons.tsx",[],["816","817"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/FileTree.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/UpdateNotification.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/layout/RightPanel.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/layout/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/AskUserQuestionUI.tsx",[],["818"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/CompletedAnswer.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/CustomInput.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/QuestionOptions.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/QuestionProgress.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/PermissionRequestItem.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/StandardPermissionUI.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/types.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/context-menu.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/dropdown-menu.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sonner.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useResize.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/fileChangeStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/uiStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/error.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/tool-names.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/message.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/integration/conductor/Conductor.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/integration/ipc/handlers.test.ts",[],["819","820","821","822","823","824","825","826","827"],"/Users/bohan/Desktop/IndexLabs/multica/tests/setup/mocks/acp-sdk.ts",[],["828","829"],"/Users/bohan/Desktop/IndexLabs/multica/tests/setup/mocks/electron.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/setup/vitest.setup.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/session/SessionStore.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-check.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-install.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/vitest.config.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/AgentProcessManager.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/G3Workaround.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/PromptHandler.ts",[],["830"],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/SessionLifecycle.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/types.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/logger.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/git.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/watcher/FileWatcher.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/watcher/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/AgentModelSelector.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ModeSelector.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/SlashCommandMenu.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/CompletedMessageFooter.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/LoadingIndicator.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/Spinner.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useChatScroll.ts",[],["831"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/commandStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/stores/draftStore.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/fileTree.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/slashCommand.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/mode.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/model.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/AgentProcessManager.test.ts",[],["832","833"],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/G3Workaround.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/PromptHandler.test.ts",[],["834","835","836","837","838","839","840","841","842","843","844","845","846","847","848","849","850","851","852","853","854","855","856"],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/SessionLifecycle.test.ts",[],["857","858"],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/git.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/FileTree.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/MessageInput.test.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/SlashCommandMenu.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/hooks/useApp.test.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/lib/utils.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/commandStore.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/draftStore.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/stores/uiStore.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/agent-version.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/permission/PermissionManager.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/agent-version.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/Settings.test.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/session/DatabaseStore.ts",[],["859"],"/Users/bohan/Desktop/IndexLabs/multica/src/main/utils/pathValidation.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ProjectItem.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/CodeBlock.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/Markdown.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/StreamingMarkdown.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/index.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/markdown/linkify.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/LiveTimer.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/path.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/shared/types/project.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/utils/pathValidation.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/watcher/FileWatcher.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/FileTreeComponent.test.tsx",[],["860"],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/utils/path.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/main/conductor/TitleGenerator.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/PlanApprovalUI.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/conductor/TitleGenerator.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/main/ipc/handlers.test.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/components/ProjectItem.test.tsx",[],["861"],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/HighlightedText.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/TextSearchBar.tsx",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useTextSearch.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/utils/messageGrouping.ts",[],[],"/Users/bohan/Desktop/IndexLabs/multica/tests/unit/renderer/hooks/useTextSearch.test.tsx",[],[],{"ruleId":"862","severity":2,"message":"863","line":34,"column":21,"nodeType":"864","messageId":"865","endLine":34,"endColumn":40,"suppressions":"866"},{"ruleId":"867","severity":1,"message":"868","line":183,"column":6,"nodeType":"869","endLine":183,"endColumn":49,"suggestions":"870","suppressions":"871"},{"ruleId":"872","severity":1,"message":"873","line":408,"column":7,"nodeType":null,"endLine":408,"endColumn":18,"suppressions":"874"},{"ruleId":"872","severity":1,"message":"875","line":432,"column":7,"nodeType":null,"endLine":432,"endColumn":18,"suppressions":"876"},{"ruleId":"877","severity":1,"message":"878","line":39,"column":17,"nodeType":"879","messageId":"880","endLine":39,"endColumn":30,"suppressions":"881"},{"ruleId":"877","severity":1,"message":"878","line":62,"column":18,"nodeType":"879","messageId":"880","endLine":62,"endColumn":32,"suppressions":"882"},{"ruleId":"883","severity":1,"message":"884","line":613,"column":26,"nodeType":null,"endLine":613,"endColumn":39,"suppressions":"885"},{"ruleId":"877","severity":1,"message":"878","line":723,"column":3,"nodeType":"879","messageId":"880","endLine":723,"endColumn":13,"suppressions":"886"},{"ruleId":"877","severity":1,"message":"878","line":46,"column":18,"nodeType":"879","messageId":"880","endLine":46,"endColumn":32,"suppressions":"887"},{"ruleId":"877","severity":1,"message":"878","line":70,"column":17,"nodeType":"879","messageId":"880","endLine":70,"endColumn":25,"suppressions":"888"},{"ruleId":"867","severity":1,"message":"889","line":268,"column":6,"nodeType":"869","endLine":268,"endColumn":8,"suggestions":"890","suppressions":"891"},{"ruleId":"867","severity":1,"message":"892","line":328,"column":6,"nodeType":"869","endLine":328,"endColumn":60,"suggestions":"893","suppressions":"894"},{"ruleId":"867","severity":1,"message":"892","line":532,"column":6,"nodeType":"869","endLine":532,"endColumn":59,"suggestions":"895","suppressions":"896"},{"ruleId":"877","severity":1,"message":"878","line":129,"column":17,"nodeType":"879","messageId":"880","endLine":129,"endColumn":29,"suppressions":"897"},{"ruleId":"877","severity":1,"message":"878","line":158,"column":17,"nodeType":"879","messageId":"880","endLine":158,"endColumn":26,"suppressions":"898"},{"ruleId":"872","severity":1,"message":"899","line":48,"column":5,"nodeType":null,"endLine":48,"endColumn":19,"suppressions":"900"},{"ruleId":"901","severity":1,"message":"902","line":49,"column":51,"nodeType":"903","messageId":"904","endLine":49,"endColumn":53,"suppressions":"905"},{"ruleId":"906","severity":1,"message":"907","line":81,"column":39,"nodeType":"908","messageId":"909","endLine":81,"endColumn":42,"suggestions":"910","suppressions":"911"},{"ruleId":"906","severity":1,"message":"907","line":81,"column":49,"nodeType":"908","messageId":"909","endLine":81,"endColumn":52,"suggestions":"912","suppressions":"913"},{"ruleId":"906","severity":1,"message":"907","line":96,"column":44,"nodeType":"908","messageId":"909","endLine":96,"endColumn":47,"suggestions":"914","suppressions":"915"},{"ruleId":"906","severity":1,"message":"907","line":96,"column":54,"nodeType":"908","messageId":"909","endLine":96,"endColumn":57,"suggestions":"916","suppressions":"917"},{"ruleId":"906","severity":1,"message":"907","line":103,"column":42,"nodeType":"908","messageId":"909","endLine":103,"endColumn":45,"suggestions":"918","suppressions":"919"},{"ruleId":"906","severity":1,"message":"907","line":103,"column":66,"nodeType":"908","messageId":"909","endLine":103,"endColumn":69,"suggestions":"920","suppressions":"921"},{"ruleId":"906","severity":1,"message":"907","line":393,"column":39,"nodeType":"908","messageId":"909","endLine":393,"endColumn":42,"suggestions":"922","suppressions":"923"},{"ruleId":"906","severity":1,"message":"907","line":423,"column":37,"nodeType":"908","messageId":"909","endLine":423,"endColumn":40,"suggestions":"924","suppressions":"925"},{"ruleId":"901","severity":1,"message":"902","line":4,"column":39,"nodeType":"903","messageId":"904","endLine":4,"endColumn":41,"suppressions":"926"},{"ruleId":"901","severity":1,"message":"902","line":17,"column":3,"nodeType":"903","messageId":"904","endLine":17,"endColumn":5,"suppressions":"927"},{"ruleId":"906","severity":1,"message":"907","line":188,"column":34,"nodeType":"908","messageId":"909","endLine":188,"endColumn":37,"suggestions":"928","suppressions":"929"},{"ruleId":"872","severity":1,"message":"930","line":103,"column":7,"nodeType":null,"endLine":103,"endColumn":20,"suppressions":"931"},{"ruleId":"906","severity":1,"message":"907","line":245,"column":29,"nodeType":"908","messageId":"909","endLine":245,"endColumn":32,"suggestions":"932","suppressions":"933"},{"ruleId":"906","severity":1,"message":"907","line":247,"column":27,"nodeType":"908","messageId":"909","endLine":247,"endColumn":30,"suggestions":"934","suppressions":"935"},{"ruleId":"906","severity":1,"message":"907","line":38,"column":27,"nodeType":"908","messageId":"909","endLine":38,"endColumn":30,"suggestions":"936","suppressions":"937"},{"ruleId":"906","severity":1,"message":"907","line":40,"column":37,"nodeType":"908","messageId":"909","endLine":40,"endColumn":40,"suggestions":"938","suppressions":"939"},{"ruleId":"906","severity":1,"message":"907","line":107,"column":57,"nodeType":"908","messageId":"909","endLine":107,"endColumn":60,"suggestions":"940","suppressions":"941"},{"ruleId":"906","severity":1,"message":"907","line":167,"column":57,"nodeType":"908","messageId":"909","endLine":167,"endColumn":60,"suggestions":"942","suppressions":"943"},{"ruleId":"906","severity":1,"message":"907","line":181,"column":61,"nodeType":"908","messageId":"909","endLine":181,"endColumn":64,"suggestions":"944","suppressions":"945"},{"ruleId":"906","severity":1,"message":"907","line":204,"column":61,"nodeType":"908","messageId":"909","endLine":204,"endColumn":64,"suggestions":"946","suppressions":"947"},{"ruleId":"906","severity":1,"message":"907","line":214,"column":61,"nodeType":"908","messageId":"909","endLine":214,"endColumn":64,"suggestions":"948","suppressions":"949"},{"ruleId":"906","severity":1,"message":"907","line":232,"column":57,"nodeType":"908","messageId":"909","endLine":232,"endColumn":60,"suggestions":"950","suppressions":"951"},{"ruleId":"906","severity":1,"message":"907","line":307,"column":57,"nodeType":"908","messageId":"909","endLine":307,"endColumn":60,"suggestions":"952","suppressions":"953"},{"ruleId":"906","severity":1,"message":"907","line":333,"column":57,"nodeType":"908","messageId":"909","endLine":333,"endColumn":60,"suggestions":"954","suppressions":"955"},{"ruleId":"906","severity":1,"message":"907","line":360,"column":57,"nodeType":"908","messageId":"909","endLine":360,"endColumn":60,"suggestions":"956","suppressions":"957"},{"ruleId":"906","severity":1,"message":"907","line":372,"column":61,"nodeType":"908","messageId":"909","endLine":372,"endColumn":64,"suggestions":"958","suppressions":"959"},{"ruleId":"906","severity":1,"message":"907","line":395,"column":61,"nodeType":"908","messageId":"909","endLine":395,"endColumn":64,"suggestions":"960","suppressions":"961"},{"ruleId":"906","severity":1,"message":"907","line":416,"column":61,"nodeType":"908","messageId":"909","endLine":416,"endColumn":64,"suggestions":"962","suppressions":"963"},{"ruleId":"906","severity":1,"message":"907","line":435,"column":61,"nodeType":"908","messageId":"909","endLine":435,"endColumn":64,"suggestions":"964","suppressions":"965"},{"ruleId":"906","severity":1,"message":"907","line":454,"column":61,"nodeType":"908","messageId":"909","endLine":454,"endColumn":64,"suggestions":"966","suppressions":"967"},{"ruleId":"906","severity":1,"message":"907","line":472,"column":61,"nodeType":"908","messageId":"909","endLine":472,"endColumn":64,"suggestions":"968","suppressions":"969"},{"ruleId":"906","severity":1,"message":"907","line":487,"column":61,"nodeType":"908","messageId":"909","endLine":487,"endColumn":64,"suggestions":"970","suppressions":"971"},{"ruleId":"906","severity":1,"message":"907","line":499,"column":61,"nodeType":"908","messageId":"909","endLine":499,"endColumn":64,"suggestions":"972","suppressions":"973"},{"ruleId":"906","severity":1,"message":"907","line":525,"column":61,"nodeType":"908","messageId":"909","endLine":525,"endColumn":64,"suggestions":"974","suppressions":"975"},{"ruleId":"906","severity":1,"message":"907","line":544,"column":61,"nodeType":"908","messageId":"909","endLine":544,"endColumn":64,"suggestions":"976","suppressions":"977"},{"ruleId":"906","severity":1,"message":"907","line":563,"column":61,"nodeType":"908","messageId":"909","endLine":563,"endColumn":64,"suggestions":"978","suppressions":"979"},{"ruleId":"906","severity":1,"message":"907","line":582,"column":61,"nodeType":"908","messageId":"909","endLine":582,"endColumn":64,"suggestions":"980","suppressions":"981"},{"ruleId":"906","severity":1,"message":"907","line":48,"column":25,"nodeType":"908","messageId":"909","endLine":48,"endColumn":28,"suggestions":"982","suppressions":"983"},{"ruleId":"906","severity":1,"message":"907","line":50,"column":23,"nodeType":"908","messageId":"909","endLine":50,"endColumn":26,"suggestions":"984","suppressions":"985"},{"ruleId":"862","severity":2,"message":"863","line":33,"column":21,"nodeType":"864","messageId":"865","endLine":33,"endColumn":40,"suppressions":"986"},{"ruleId":"987","severity":1,"message":"988","line":5,"column":13,"nodeType":"879","messageId":"989","endLine":5,"endColumn":18,"suggestions":"990","suppressions":"991"},{"ruleId":"992","severity":2,"message":"993","line":17,"column":5,"nodeType":"994","messageId":"995","endLine":17,"endColumn":12,"suppressions":"996"},"@typescript-eslint/no-require-imports","A `require()` style import is forbidden.","CallExpression","noRequireImports",["997"],"react-hooks/exhaustive-deps","React Hook useEffect has missing dependencies: 'images' and 'value'. Either include them or remove the dependency array.","ArrayExpression",["998"],["999"],"react-hooks/set-state-in-effect","Error: Calling setState synchronously within an effect can trigger cascading renders\n\nEffects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).\n\n/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Settings.tsx:408:7\n 406 | if (isHighlighted) {\n 407 | // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: sync UI state with prop\n> 408 | setExpanded(true)\n | ^^^^^^^^^^^ Avoid calling setState() directly within an effect\n 409 | }\n 410 | }, [isHighlighted])\n 411 |",["1000"],"Error: Calling setState synchronously within an effect can trigger cascading renders\n\nEffects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).\n\n/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/Settings.tsx:432:7\n 430 | if (isWaiting) {\n 431 | // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: sync UI state with install status\n> 432 | setExpanded(true)\n | ^^^^^^^^^^^ Avoid calling setState() directly within an effect\n 433 | }\n 434 | }, [isWaiting])\n 435 |",["1001"],"react-refresh/only-export-components","Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","Identifier","namedExport",["1002"],["1003"],"react-hooks/purity","Error: Cannot call impure function during render\n\n`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).\n\n/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/ui/sidebar.tsx:613:26\n 611 | const width = React.useMemo(() => {\n 612 | // eslint-disable-next-line react-hooks/purity -- Intentional: random width for skeleton loading effect\n> 613 | return `${Math.floor(Math.random() * 40) + 50}%`\n | ^^^^^^^^^^^^^ Cannot call impure function\n 614 | }, [])\n 615 |\n 616 | return (",["1004"],["1005"],["1006"],["1007"],"React Hook useEffect has missing dependencies: 'loadProjectsAndSessions' and 'loadRunningStatus'. Either include them or remove the dependency array.",["1008"],["1009"],"React Hook useEffect has a missing dependency: 'currentSession'. Either include it or remove the dependency array.",["1010"],["1011"],["1012"],["1013"],["1014"],["1015"],"Error: Calling setState synchronously within an effect can trigger cascading renders\n\nEffects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).\n\n/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/components/permission/AskUserQuestion/AskUserQuestionUI.tsx:48:5\n 46 | /* eslint-disable react-hooks/set-state-in-effect -- Intentional reset on question change */\n 47 | useEffect(() => {\n> 48 | setCustomInput('')\n | ^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect\n 49 | setSelectedOptions([])\n 50 | }, [currentQuestionIndex])\n 51 | /* eslint-enable react-hooks/set-state-in-effect */",["1016"],"@typescript-eslint/explicit-function-return-type","Missing return type on function.","ArrowFunctionExpression","missingReturnType",["1017"],"@typescript-eslint/no-explicit-any","Unexpected any. Specify a different type.","TSAnyKeyword","unexpectedAny",["1018","1019"],["1020"],["1021","1022"],["1023"],["1024","1025"],["1026"],["1027","1028"],["1029"],["1030","1031"],["1032"],["1033","1034"],["1035"],["1036","1037"],["1038"],["1039","1040"],["1041"],["1042"],["1043"],["1044","1045"],["1046"],"Error: Calling setState synchronously within an effect can trigger cascading renders\n\nEffects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).\n\n/Users/bohan/Desktop/IndexLabs/multica/src/renderer/src/hooks/useChatScroll.ts:103:7\n 101 | isAtBottomRef.current = true\n 102 | // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: need to reset UI state on session change\n> 103 | setIsAtBottom(true)\n | ^^^^^^^^^^^^^ Avoid calling setState() directly within an effect\n 104 | }\n 105 | }, [sessionId, cleanup])\n 106 |",["1047"],["1048","1049"],["1050"],["1051","1052"],["1053"],["1054","1055"],["1056"],["1057","1058"],["1059"],["1060","1061"],["1062"],["1063","1064"],["1065"],["1066","1067"],["1068"],["1069","1070"],["1071"],["1072","1073"],["1074"],["1075","1076"],["1077"],["1078","1079"],["1080"],["1081","1082"],["1083"],["1084","1085"],["1086"],["1087","1088"],["1089"],["1090","1091"],["1092"],["1093","1094"],["1095"],["1096","1097"],["1098"],["1099","1100"],["1101"],["1102","1103"],["1104"],["1105","1106"],["1107"],["1108","1109"],["1110"],["1111","1112"],["1113"],["1114","1115"],["1116"],["1117","1118"],["1119"],["1120","1121"],["1122"],["1123","1124"],["1125"],["1126","1127"],["1128"],["1129"],"@typescript-eslint/no-unused-vars","'React' is defined but never used.","unusedVar",["1130"],["1131"],"react/prop-types","'onClick' is missing in props validation","Property","missingPropType",["1132"],{"kind":"1133","justification":"1134"},{"desc":"1135","fix":"1136"},{"kind":"1133","justification":"1137"},{"kind":"1133","justification":"1138"},{"kind":"1133","justification":"1139"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1140"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"desc":"1141","fix":"1142"},{"kind":"1133","justification":"1143"},{"desc":"1144","fix":"1145"},{"kind":"1133","justification":"1146"},{"desc":"1147","fix":"1148"},{"kind":"1133","justification":"1149"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1150"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1152","desc":"1153"},{"messageId":"1154","fix":"1155","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1157","desc":"1153"},{"messageId":"1154","fix":"1158","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1159","desc":"1153"},{"messageId":"1154","fix":"1160","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1161","desc":"1153"},{"messageId":"1154","fix":"1162","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1163","desc":"1153"},{"messageId":"1154","fix":"1164","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1165","desc":"1153"},{"messageId":"1154","fix":"1166","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1167","desc":"1153"},{"messageId":"1154","fix":"1168","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1169","desc":"1153"},{"messageId":"1154","fix":"1170","desc":"1156"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1171","desc":"1153"},{"messageId":"1154","fix":"1172","desc":"1156"},{"kind":"1133","justification":"1173"},{"kind":"1133","justification":"1174"},{"messageId":"1151","fix":"1175","desc":"1153"},{"messageId":"1154","fix":"1176","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1177","desc":"1153"},{"messageId":"1154","fix":"1178","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1179","desc":"1153"},{"messageId":"1154","fix":"1180","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1181","desc":"1153"},{"messageId":"1154","fix":"1182","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1183","desc":"1153"},{"messageId":"1154","fix":"1184","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1185","desc":"1153"},{"messageId":"1154","fix":"1186","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1187","desc":"1153"},{"messageId":"1154","fix":"1188","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1189","desc":"1153"},{"messageId":"1154","fix":"1190","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1191","desc":"1153"},{"messageId":"1154","fix":"1192","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1193","desc":"1153"},{"messageId":"1154","fix":"1194","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1195","desc":"1153"},{"messageId":"1154","fix":"1196","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1197","desc":"1153"},{"messageId":"1154","fix":"1198","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1199","desc":"1153"},{"messageId":"1154","fix":"1200","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1201","desc":"1153"},{"messageId":"1154","fix":"1202","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1203","desc":"1153"},{"messageId":"1154","fix":"1204","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1205","desc":"1153"},{"messageId":"1154","fix":"1206","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1207","desc":"1153"},{"messageId":"1154","fix":"1208","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1209","desc":"1153"},{"messageId":"1154","fix":"1210","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1211","desc":"1153"},{"messageId":"1154","fix":"1212","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1213","desc":"1153"},{"messageId":"1154","fix":"1214","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1215","desc":"1153"},{"messageId":"1154","fix":"1216","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1217","desc":"1153"},{"messageId":"1154","fix":"1218","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1219","desc":"1153"},{"messageId":"1154","fix":"1220","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1221","desc":"1153"},{"messageId":"1154","fix":"1222","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1223","desc":"1153"},{"messageId":"1154","fix":"1224","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1225","desc":"1153"},{"messageId":"1154","fix":"1226","desc":"1156"},{"kind":"1133","justification":"1134"},{"messageId":"1151","fix":"1227","desc":"1153"},{"messageId":"1154","fix":"1228","desc":"1156"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},{"messageId":"1229","data":"1230","fix":"1231","desc":"1232"},{"kind":"1133","justification":"1134"},{"kind":"1133","justification":"1134"},"directive","","Update the dependencies array to be: [sessionId, getDraft, setDraft, clearDraft, value, images]",{"range":"1233","text":"1234"},"Intentional: only react to session changes; draft changes handled elsewhere","Intentional: sync UI state with prop","Intentional: sync UI state with install status","Intentional: random width for skeleton loading effect","Update the dependencies array to be: [loadProjectsAndSessions, loadRunningStatus]",{"range":"1235","text":"1236"},"Intentional: only load on mount","Update the dependencies array to be: [currentSession, currentSession.id, currentSession.workingDirectory]",{"range":"1237","text":"1238"},"Intentional: only re-run when id or workingDirectory changes","Update the dependencies array to be: [currentSession, currentSession.id, validateCurrentSessionDirectory]",{"range":"1239","text":"1240"},"Intentional: only re-subscribe on session ID change","Intentional reset on question change","suggestUnknown",{"range":"1241","text":"1242"},"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct.","suggestNever",{"range":"1243","text":"1244"},"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of.",{"range":"1245","text":"1242"},{"range":"1246","text":"1244"},{"range":"1247","text":"1242"},{"range":"1248","text":"1244"},{"range":"1249","text":"1242"},{"range":"1250","text":"1244"},{"range":"1251","text":"1242"},{"range":"1252","text":"1244"},{"range":"1253","text":"1242"},{"range":"1254","text":"1244"},{"range":"1255","text":"1242"},{"range":"1256","text":"1244"},{"range":"1257","text":"1242"},{"range":"1258","text":"1244"},{"range":"1259","text":"1242"},{"range":"1260","text":"1244"},"ACP SDK types don't match our MessageContentItem","Intentional: need to reset UI state on session change",{"range":"1261","text":"1242"},{"range":"1262","text":"1244"},{"range":"1263","text":"1242"},{"range":"1264","text":"1244"},{"range":"1265","text":"1242"},{"range":"1266","text":"1244"},{"range":"1267","text":"1242"},{"range":"1268","text":"1244"},{"range":"1269","text":"1242"},{"range":"1270","text":"1244"},{"range":"1271","text":"1242"},{"range":"1272","text":"1244"},{"range":"1273","text":"1242"},{"range":"1274","text":"1244"},{"range":"1275","text":"1242"},{"range":"1276","text":"1244"},{"range":"1277","text":"1242"},{"range":"1278","text":"1244"},{"range":"1279","text":"1242"},{"range":"1280","text":"1244"},{"range":"1281","text":"1242"},{"range":"1282","text":"1244"},{"range":"1283","text":"1242"},{"range":"1284","text":"1244"},{"range":"1285","text":"1242"},{"range":"1286","text":"1244"},{"range":"1287","text":"1242"},{"range":"1288","text":"1244"},{"range":"1289","text":"1242"},{"range":"1290","text":"1244"},{"range":"1291","text":"1242"},{"range":"1292","text":"1244"},{"range":"1293","text":"1242"},{"range":"1294","text":"1244"},{"range":"1295","text":"1242"},{"range":"1296","text":"1244"},{"range":"1297","text":"1242"},{"range":"1298","text":"1244"},{"range":"1299","text":"1242"},{"range":"1300","text":"1244"},{"range":"1301","text":"1242"},{"range":"1302","text":"1244"},{"range":"1303","text":"1242"},{"range":"1304","text":"1244"},{"range":"1305","text":"1242"},{"range":"1306","text":"1244"},{"range":"1307","text":"1242"},{"range":"1308","text":"1244"},{"range":"1309","text":"1242"},{"range":"1310","text":"1244"},{"range":"1311","text":"1242"},{"range":"1312","text":"1244"},{"range":"1313","text":"1242"},{"range":"1314","text":"1244"},"removeUnusedImportDeclaration",{"varName":"1315"},{"range":"1316","text":"1134"},"Remove unused import declaration.",[6695,6738],"[sessionId, getDraft, setDraft, clearDraft, value, images]",[9241,9243],"[loadProjectsAndSessions, loadRunningStatus]",[11440,11494],"[currentSession, currentSession.id, currentSession.workingDirectory]",[18699,18752],"[currentSession, currentSession.id, validateCurrentSessionDirectory]",[2566,2569],"unknown",[2566,2569],"never",[2576,2579],[2576,2579],[3169,3172],[3169,3172],[3179,3182],[3179,3182],[3434,3437],[3434,3437],[3458,3461],[3458,3461],[13199,13202],[13199,13202],[14305,14308],[14305,14308],[7023,7026],[7023,7026],[8029,8032],[8029,8032],[8131,8134],[8131,8134],[1165,1168],[1165,1168],[1275,1278],[1275,1278],[3342,3345],[3342,3345],[5420,5423],[5420,5423],[6092,6095],[6092,6095],[6940,6943],[6940,6943],[7364,7367],[7364,7367],[8019,8022],[8019,8022],[10476,10479],[10476,10479],[11384,11387],[11384,11387],[12459,12462],[12459,12462],[12936,12939],[12936,12939],[13753,13756],[13753,13756],[14502,14505],[14502,14505],[15267,15270],[15267,15270],[16024,16027],[16024,16027],[16852,16855],[16852,16855],[17522,17525],[17522,17525],[18038,18041],[18038,18041],[19070,19073],[19070,19073],[19783,19786],[19783,19786],[20484,20487],[20484,20487],[21183,21186],[21183,21186],[1372,1375],[1372,1375],[1466,1469],[1466,1469],"React",[99,129]] \ No newline at end of file diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index 397d8f88..50b9f80e 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -314,18 +314,8 @@ Run the local daemon: make daemon ``` -Normal flow: - -1. start the daemon -2. open the pairing link it prints -3. choose the workspace in the browser -4. let the daemon register its local runtime - -Debug shortcut: - -- you can set `MULTICA_WORKSPACE_ID` in your env file -- this skips normal pairing -- treat it as a local shortcut, not the default workflow +The daemon authenticates using the CLI's stored token (`multica login`). +It registers runtimes for all watched workspaces from the CLI config. ## Troubleshooting diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 97f1416c..9063f91d 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -27,6 +27,7 @@ import { ChevronDown, Globe, Lock, + Settings, } from "lucide-react"; import type { Agent, @@ -1154,11 +1155,143 @@ function TasksTab({ agent }: { agent: Agent }) { ); } +// --------------------------------------------------------------------------- +// Settings Tab +// --------------------------------------------------------------------------- + +function SettingsTab({ + agent, + runtimes, + onSave, +}: { + agent: Agent; + runtimes: RuntimeDevice[]; + onSave: (updates: Partial) => Promise; +}) { + const [name, setName] = useState(agent.name); + const [description, setDescription] = useState(agent.description ?? ""); + const [visibility, setVisibility] = useState(agent.visibility); + const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks); + const [saving, setSaving] = useState(false); + + const dirty = + name !== agent.name || + description !== (agent.description ?? "") || + visibility !== agent.visibility || + maxTasks !== agent.max_concurrent_tasks; + + const handleSave = async () => { + if (!name.trim()) { + toast.error("Name is required"); + return; + } + setSaving(true); + try { + await onSave({ name: name.trim(), description, visibility, max_concurrent_tasks: maxTasks }); + toast.success("Settings saved"); + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const runtimeDevice = runtimes.find((r) => r.id === agent.runtime_id); + + return ( +
+
+ + setName(e.target.value)} + className="mt-1" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="What does this agent do?" + className="mt-1" + /> +
+ +
+ +
+ + +
+
+ +
+ + setMaxTasks(Number(e.target.value))} + className="mt-1 w-24" + /> +
+ +
+ +
+ {agent.runtime_mode === "cloud" ? ( + + ) : ( + + )} + {runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")} +
+
+ + +
+ ); +} + // --------------------------------------------------------------------------- // Agent Detail // --------------------------------------------------------------------------- -type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks"; +type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings"; const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [ { id: "instructions", label: "Instructions", icon: FileText }, @@ -1166,6 +1299,7 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [ { id: "tools", label: "Tools", icon: Wrench }, { id: "triggers", label: "Triggers", icon: Timer }, { id: "tasks", label: "Tasks", icon: ListTodo }, + { id: "settings", label: "Settings", icon: Settings }, ]; function AgentDetail({ @@ -1270,6 +1404,13 @@ function AgentDetail({ /> )} {activeTab === "tasks" && } + {activeTab === "settings" && ( + onUpdate(agent.id, updates)} + /> + )} {/* Delete Confirmation */} diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 9d3b62d2..83501a99 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -219,9 +219,9 @@ function InboxListItem({ export default function InboxPage() { const searchParams = useSearchParams(); - const selectedId = searchParams.get("id") ?? ""; - const setSelectedId = (id: string) => { - const url = id ? `/inbox?id=${id}` : "/inbox"; + const selectedKey = searchParams.get("issue") ?? ""; + const setSelectedKey = (key: string) => { + const url = key ? `/inbox?issue=${key}` : "/inbox"; window.history.replaceState(null, "", url); }; @@ -232,12 +232,12 @@ export default function InboxPage() { id: "multica_inbox_layout", }); - const selected = items.find((i) => i.id === selectedId) ?? null; + const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null; const unreadCount = items.filter((i) => !i.read).length; // Click-to-read: select + auto-mark-read const handleSelect = async (item: InboxItem) => { - setSelectedId(item.id); + setSelectedKey(item.issue_id ?? item.id); if (!item.read) { useInboxStore.getState().markRead(item.id); try { @@ -254,7 +254,8 @@ export default function InboxPage() { try { await api.archiveInbox(id); useInboxStore.getState().archive(id); - if (selectedId === id) setSelectedId(""); + const archived = items.find((i) => i.id === id); + if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); } catch { toast.error("Failed to archive"); } @@ -274,7 +275,7 @@ export default function InboxPage() { const handleArchiveAll = async () => { try { useInboxStore.getState().archiveAll(); - setSelectedId(""); + setSelectedKey(""); await api.archiveAllInbox(); } catch { toast.error("Failed to archive all"); @@ -284,9 +285,9 @@ export default function InboxPage() { const handleArchiveAllRead = async () => { try { - const readIds = items.filter((i) => i.read).map((i) => i.id); + const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); useInboxStore.getState().archiveAllRead(); - if (readIds.includes(selectedId)) setSelectedId(""); + if (readKeys.includes(selectedKey)) setSelectedKey(""); await api.archiveAllReadInbox(); } catch { toast.error("Failed to archive read items"); @@ -297,7 +298,7 @@ export default function InboxPage() { const handleArchiveCompleted = async () => { try { await api.archiveCompletedInbox(); - setSelectedId(""); + setSelectedKey(""); await useInboxStore.getState().fetch(); } catch { toast.error("Failed to archive completed"); @@ -395,7 +396,7 @@ export default function InboxPage() { handleSelect(item)} onArchive={() => handleArchive(item.id)} /> diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..065d8051 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({ if (type === "agent") return "CA"; return "??"; }, + getActorAvatarUrl: () => null, }), })); @@ -296,6 +297,7 @@ describe("IssueDetailPage", () => { author_id: "user-1", parent_id: null, reactions: [], + attachments: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c7af7dcc..5e9d662e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({ getActorName: (type: string, id: string) => type === "member" ? "Test User" : "Claude Agent", getActorInitials: () => "TU", + getActorAvatarUrl: () => null, }), useWorkspaceStore: Object.assign( (selector?: any) => { diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index d3ecb705..78f3524e 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Camera, Loader2, Save } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); const setUser = useAuthStore((s) => s.setUser); const [profileName, setProfileName] = useState(user?.name ?? ""); - const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [profileSaving, setProfileSaving] = useState(false); + const { upload, uploading } = useFileUpload(); + const fileInputRef = useRef(null); useEffect(() => { setProfileName(user?.name ?? ""); - setAvatarUrl(user?.avatar_url ?? ""); }, [user]); + const initials = (user?.name ?? "") + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so the same file can be re-selected + e.target.value = ""; + try { + const result = await upload(file); + if (!result) return; + const updated = await api.updateMe({ avatar_url: result.link }); + setUser(updated); + toast.success("Avatar updated"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to upload avatar"); + } + }; + const handleProfileSave = async () => { setProfileSaving(true); try { - const updated = await api.updateMe({ - name: profileName, - avatar_url: avatarUrl || undefined, - }); + const updated = await api.updateMe({ name: profileName }); setUser(updated); toast.success("Profile updated"); } catch (e) { @@ -45,7 +66,46 @@ export function AccountTab() {

Profile

- + + {/* Avatar upload */} +
+ + +
+ Click to upload avatar +
+
+
-
- - setAvatarUrl(e.target.value)} - placeholder="https://example.com/avatar.png" - className="mt-1" - /> -
-
- )} - - ) : null} - - - ); -} - -export default function LocalDaemonPairPage() { - return ( - - - - ); -} diff --git a/apps/web/components/common/actor-avatar.tsx b/apps/web/components/common/actor-avatar.tsx index ad9d8d48..a50d71cc 100644 --- a/apps/web/components/common/actor-avatar.tsx +++ b/apps/web/components/common/actor-avatar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { Bot } from "lucide-react"; import { cn } from "@/lib/utils"; import { useActorName } from "@/features/workspace"; @@ -8,8 +9,10 @@ interface ActorAvatarProps { actorType: string; actorId: string; size?: number; + avatarUrl?: string | null; getName?: (type: string, id: string) => string; getInitials?: (type: string, id: string) => string; + getAvatarUrl?: (type: string, id: string) => string | null; className?: string; } @@ -17,29 +20,47 @@ function ActorAvatar({ actorType, actorId, size = 20, + avatarUrl, getName, getInitials, + getAvatarUrl, className, }: ActorAvatarProps) { const actorNameHook = useActorName(); const resolveName = getName ?? actorNameHook.getActorName; const resolveInitials = getInitials ?? actorNameHook.getActorInitials; + const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl; const name = resolveName(actorType, actorId); const initials = resolveInitials(actorType, actorId); const isAgent = actorType === "agent"; + const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId); + + const [imgError, setImgError] = useState(false); + + // Reset error state when URL changes (e.g. user uploads new avatar) + useEffect(() => { + setImgError(false); + }, [resolvedUrl]); return (
- {isAgent ? ( + {resolvedUrl && !imgError ? ( + {name} setImgError(true)} + /> + ) : isAgent ? ( ) : ( initials diff --git a/apps/web/components/common/mention-hover-card.tsx b/apps/web/components/common/mention-hover-card.tsx new file mode 100644 index 00000000..f54d4ad2 --- /dev/null +++ b/apps/web/components/common/mention-hover-card.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Bot } from "lucide-react"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { useWorkspaceStore } from "@/features/workspace"; + +interface MentionHoverCardProps { + type: string; + id: string; + children: ReactNode; +} + +function MentionHoverCard({ type, id, children }: MentionHoverCardProps) { + const members = useWorkspaceStore((s) => s.members); + const agents = useWorkspaceStore((s) => s.agents); + + if (type === "member") { + const member = members.find((m) => m.user_id === id); + if (!member) return <>{children}; + + return ( + + } className="cursor-default"> + {children} + + +
+ +
+

{member.name}

+

{member.email}

+
+
+
+
+ ); + } + + if (type === "agent") { + const agent = agents.find((a) => a.id === id); + if (!agent) return <>{children}; + + return ( + + } className="cursor-default"> + {children} + + +
+
+ +
+
+

{agent.name}

+ {agent.description && ( +

{agent.description}

+ )} +
+
+
+
+ ); + } + + return <>{children}; +} + +export { MentionHoverCard }; diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 47c3c416..f387d05d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -126,7 +126,11 @@ /* Links */ .rich-text-editor a { - color: var(--primary); + color: var(--brand); + text-decoration: none; +} + +.rich-text-editor a:hover { text-decoration: underline; text-underline-offset: 2px; } @@ -134,11 +138,9 @@ /* Mentions */ .rich-text-editor .mention { color: var(--primary); - background: color-mix(in srgb, var(--primary) 8%, transparent); - padding: 0 0.2em; - border-radius: calc(var(--radius) * 0.5); - font-weight: 500; + font-weight: 600; text-decoration: none; + margin: 0 0.125rem; } /* Strong / emphasis */ diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 0cd67ec9..3ad14252 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -12,9 +12,12 @@ import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import Typography from "@tiptap/extension-typography"; import Mention from "@tiptap/extension-mention"; +import Image from "@tiptap/extension-image"; import { Markdown } from "@tiptap/markdown"; -import { Extension } from "@tiptap/core"; +import { Extension, mergeAttributes } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; import { cn } from "@/lib/utils"; +import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { createMentionSuggestion } from "./mention-suggestion"; import "./rich-text-editor.css"; @@ -30,56 +33,22 @@ interface RichTextEditorProps { className?: string; debounceMs?: number; onSubmit?: () => void; + onUploadFile?: (file: File) => Promise; } interface RichTextEditorRef { getMarkdown: () => string; clearContent: () => void; focus: () => void; + insertFile: (filename: string, url: string, isImage: boolean) => void; } -// --------------------------------------------------------------------------- -// Submit shortcut extension (Mod+Enter) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Mention extension configured for markdown serialization -// Stores as: [@Label](mention://type/id) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Link extension — always serialize as [text](url), never autolinks; -// support Cmd+Click / Ctrl+Click to open in new tab. -// --------------------------------------------------------------------------- - const LinkExtension = Link.configure({ openOnClick: true, autolink: true, HTMLAttributes: { class: "text-primary hover:underline cursor-pointer", }, -}).extend({ - addStorage() { - return { - markdown: { - serialize: { - open() { - return "["; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - close(_state: any, mark: any) { - const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&"); - const title = mark.attrs.title - ? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"` - : ""; - return `](${href}${title})`; - }, - mixable: true, - }, - parse: {}, - }, - }; - }, }); const MentionExtension = Mention.configure({ @@ -87,17 +56,18 @@ const MentionExtension = Mention.configure({ suggestion: createMentionSuggestion(), }).extend({ renderHTML({ node, HTMLAttributes }) { - const type = node.attrs.type ?? "member"; - const label = node.attrs.label ?? node.attrs.id; return [ - "a", - { - ...HTMLAttributes, - href: `mention://${type}/${node.attrs.id}`, - "data-mention-type": type, - "data-mention-id": node.attrs.id, - }, - type === "issue" ? label : `@${label}`, + "span", + mergeAttributes( + { "data-type": "mention" }, + this.options.HTMLAttributes, + HTMLAttributes, + { + "data-mention-type": node.attrs.type ?? "member", + "data-mention-id": node.attrs.id, + }, + ), + `@${node.attrs.label ?? node.attrs.id}`, ]; }, addAttributes() { @@ -105,21 +75,39 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", - }, - description: { - default: null, - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"), + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-type") ?? "member", + renderHTML: () => ({}), }, }; }, - // @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field + // @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id) + markdownTokenizer: { + name: "mention", + level: "inline" as const, + start(src: string) { + return src.search(/\[@[^\]]+\]\(mention:\/\//); + }, + tokenize(src: string) { + const match = src.match( + /^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, + ); + if (!match) return undefined; + return { + type: "mention", + raw: match[0], + attributes: { label: match[1], type: match[2], id: match[3] }, + }; + }, + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderMarkdown(node: any) { - const type = node.attrs?.type ?? "member"; - const label = node.attrs?.label ?? node.attrs?.id; - const display = type === "issue" ? label : `@${label}`; - return `[${display}](mention://${type}/${node.attrs?.id})`; + parseMarkdown: (token: any, helpers: any) => { + return helpers.createNode("mention", token.attributes); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderMarkdown: (node: any) => { + const { id, label, type = "member" } = node.attrs || {}; + return `[@${label ?? id}](mention://${type}/${id})`; }, }); @@ -141,6 +129,77 @@ function createSubmitExtension(onSubmit: () => void) { }); } +// --------------------------------------------------------------------------- +// File upload extension (paste + drop) +// --------------------------------------------------------------------------- + +function createFileUploadExtension( + onUploadFileRef: React.RefObject<((file: File) => Promise) | undefined>, +) { + return Extension.create({ + name: "fileUpload", + addProseMirrorPlugins() { + const { editor } = this; + + const handleFiles = async (files: FileList, pos?: number) => { + const handler = onUploadFileRef.current; + if (!handler) return false; + + let handled = false; + for (const file of Array.from(files)) { + handled = true; + try { + const result = await handler(file); + if (!result) continue; + + const isImage = file.type.startsWith("image/"); + if (isImage) { + editor + .chain() + .focus() + .setImage({ src: result.link, alt: result.filename }) + .run(); + } else { + // Insert as a markdown link + const linkText = `[${result.filename}](${result.link})`; + if (pos !== undefined) { + editor.chain().focus().insertContentAt(pos, linkText).run(); + } else { + editor.chain().focus().insertContent(linkText).run(); + } + } + } catch { + // Upload errors handled by the hook/caller via toast + } + } + return handled; + }; + + return [ + new Plugin({ + key: new PluginKey("fileUpload"), + props: { + handlePaste(_view, event) { + const files = event.clipboardData?.files; + if (!files?.length) return false; + if (!onUploadFileRef.current) return false; + handleFiles(files); + return true; + }, + handleDrop(_view, event) { + const files = (event as DragEvent).dataTransfer?.files; + if (!files?.length) return false; + if (!onUploadFileRef.current) return false; + handleFiles(files); + return true; + }, + }, + }), + ]; + }, + }); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -155,26 +214,25 @@ const RichTextEditor = forwardRef( className, debounceMs = 300, onSubmit, + onUploadFile, }, ref, ) { const debounceRef = useRef>(undefined); const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); - - // Helper to get markdown from @tiptap/markdown extension - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getEditorMarkdown = (ed: any): string => - ed?.getMarkdown?.() ?? ""; + const onUploadFileRef = useRef(onUploadFile); // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; + onUploadFileRef.current = onUploadFile; const editor = useEditor({ immediatelyRender: false, editable, - content: defaultValue, + content: defaultValue || "", + contentType: defaultValue ? "markdown" : undefined, extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, @@ -186,14 +244,20 @@ const RichTextEditor = forwardRef( LinkExtension, Typography, MentionExtension, + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { style: "max-width: 100%; height: auto;" }, + }), Markdown, createSubmitExtension(() => onSubmitRef.current?.()), + createFileUploadExtension(onUploadFileRef), ], onUpdate: ({ editor: ed }) => { if (!onUpdateRef.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - onUpdateRef.current?.(getEditorMarkdown(ed)); + onUpdateRef.current?.(ed.getMarkdown()); }, debounceMs); }, editorProps: { @@ -225,13 +289,21 @@ const RichTextEditor = forwardRef( }, []); useImperativeHandle(ref, () => ({ - getMarkdown: () => getEditorMarkdown(editor), + getMarkdown: () => editor?.getMarkdown() ?? "", clearContent: () => { editor?.commands.clearContent(); }, focus: () => { editor?.commands.focus(); }, + insertFile: (filename: string, url: string, isImage: boolean) => { + if (!editor) return; + if (isImage) { + editor.chain().focus().setImage({ src: url, alt: filename }).run(); + } else { + editor.chain().focus().insertContent(`[${filename}](${url})`).run(); + } + }, })); if (!editor) return null; diff --git a/apps/web/components/common/title-editor.css b/apps/web/components/common/title-editor.css new file mode 100644 index 00000000..70d63a06 --- /dev/null +++ b/apps/web/components/common/title-editor.css @@ -0,0 +1,18 @@ +/* Title editor: minimal ProseMirror for single-line titles */ + +.title-editor.ProseMirror { + outline: none; +} + +.title-editor.ProseMirror p { + margin: 0; +} + +/* Placeholder */ +.title-editor .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + pointer-events: none; + height: 0; +} diff --git a/apps/web/components/common/title-editor.tsx b/apps/web/components/common/title-editor.tsx new file mode 100644 index 00000000..14837b27 --- /dev/null +++ b/apps/web/components/common/title-editor.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import Placeholder from "@tiptap/extension-placeholder"; +import { cn } from "@/lib/utils"; +import "./title-editor.css"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TitleEditorProps { + defaultValue?: string; + placeholder?: string; + className?: string; + autoFocus?: boolean; + onSubmit?: () => void; + onBlur?: (value: string) => void; + onChange?: (value: string) => void; +} + +interface TitleEditorRef { + getText: () => string; + focus: () => void; +} + +// --------------------------------------------------------------------------- +// Single-paragraph document — prevents Enter from creating new lines +// --------------------------------------------------------------------------- + +const SingleLineDocument = Document.extend({ + content: "paragraph", +}); + +// --------------------------------------------------------------------------- +// Keyboard shortcuts: Enter → submit, Escape → blur +// --------------------------------------------------------------------------- + +function createTitleKeymap(opts: { + onSubmitRef: React.RefObject<(() => void) | undefined>; +}) { + return Extension.create({ + name: "titleKeymap", + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + opts.onSubmitRef.current?.(); + editor.commands.blur(); + return true; + }, + "Shift-Enter": () => true, // swallow — no line breaks + Escape: ({ editor }) => { + editor.commands.blur(); + return true; + }, + }; + }, + }); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const TitleEditor = forwardRef( + function TitleEditor( + { + defaultValue = "", + placeholder: placeholderText = "", + className, + autoFocus = false, + onSubmit, + onBlur, + onChange, + }, + ref, + ) { + const onSubmitRef = useRef(onSubmit); + const onBlurRef = useRef(onBlur); + const onChangeRef = useRef(onChange); + + onSubmitRef.current = onSubmit; + onBlurRef.current = onBlur; + onChangeRef.current = onChange; + + const editor = useEditor({ + immediatelyRender: false, + content: `

${defaultValue}

`, + extensions: [ + SingleLineDocument, + Paragraph, + Text, + Placeholder.configure({ + placeholder: placeholderText, + showOnlyCurrent: false, + }), + createTitleKeymap({ onSubmitRef }), + ], + editorProps: { + attributes: { + class: cn("title-editor outline-none", className), + role: "textbox", + "aria-multiline": "false", + "aria-label": placeholderText || "Title", + }, + }, + onUpdate: ({ editor: ed }) => { + onChangeRef.current?.(ed.getText()); + }, + onBlur: ({ editor: ed }) => { + onBlurRef.current?.(ed.getText()); + }, + }); + + // Auto-focus after mount + useEffect(() => { + if (autoFocus && editor) { + // Move cursor to end + editor.commands.focus("end"); + } + }, [autoFocus, editor]); + + useImperativeHandle(ref, () => ({ + getText: () => editor?.getText() ?? "", + focus: () => { + editor?.commands.focus("end"); + }, + })); + + if (!editor) return null; + + return ; + }, +); + +export { TitleEditor, type TitleEditorProps, type TitleEditorRef }; diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 6ddff630..1c781dec 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -66,6 +66,15 @@ function createComponents( onFileClick?: (path: string) => void ): Partial { const baseComponents: Partial = { + // Images: render uploaded images with constrained sizing + img: ({ src, alt }) => ( + {alt + ), // Links: Make clickable with callbacks, or render as mention a: ({ href, children }) => { // Mention links: mention://member/id, mention://agent/id, mention://issue/id @@ -76,10 +85,7 @@ function createComponents( return } return ( - + {children} ) diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index edf56959..e52365f4 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { X, Trash2, Bot, UserMinus } from "lucide-react"; +import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -19,8 +19,9 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; -import type { UpdateIssueRequest } from "@/shared/types"; +import type { Agent, UpdateIssueRequest } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; +import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useIssueStore } from "@/features/issues/store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; @@ -206,6 +207,13 @@ export function BatchActionToolbar() { ); } +function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean { + if (agent.visibility !== "private") return true; + if (agent.owner_id === userId) return true; + if (memberRole === "owner" || memberRole === "admin") return true; + return false; +} + function BatchAssigneePicker({ open, onOpenChange, @@ -218,10 +226,14 @@ function BatchAssigneePicker({ loading: boolean; }) { const [filter, setFilter] = useState(""); + const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); const { getActorInitials } = useActorName(); + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const query = filter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), @@ -297,22 +309,30 @@ function BatchAssigneePicker({
Agents
- {filteredAgents.map((a) => ( - - ))} + {filteredAgents.map((a) => { + const allowed = canAssignAgent(a, user?.id, memberRole); + return ( + + ); + })}
)} diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 537b469a..2bea2dda 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Copy, MoreHorizontal, Pencil, Trash2, ChevronRight } from "lucide-react"; +import { useRef, useState } from "react"; +import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -16,10 +16,10 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { ReactionBar } from "@/components/common/reaction-bar"; -import { Markdown } from "@/components/markdown"; import { cn } from "@/lib/utils"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ReplyInput } from "./reply-input"; import type { TimelineEntry } from "@/shared/types"; @@ -28,6 +28,7 @@ import type { TimelineEntry } from "@/shared/types"; // --------------------------------------------------------------------------- interface CommentCardProps { + issueId: string; entry: TimelineEntry; allReplies: Map; currentUserId?: string; @@ -56,28 +57,28 @@ function CommentRow({ }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); - const [editContent, setEditContent] = useState(""); + const editEditorRef = useRef(null); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { - setEditContent(entry.content ?? ""); setEditing(true); }; const cancelEdit = () => { setEditing(false); - setEditContent(""); }; const saveEdit = async () => { - const trimmed = editContent.trim(); + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); if (!trimmed) return; try { await onEdit(entry.id, trimmed); setEditing(false); - setEditContent(""); } catch { toast.error("Failed to update comment"); } @@ -142,27 +143,28 @@ function CommentRow({ {editing ? ( -
{ e.preventDefault(); saveEdit(); }} +
{ if (e.key === "Escape") cancelEdit(); }} > - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b border-border outline-none py-1" - onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} - /> -
- - +
+
- +
+ + +
+
) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( (null); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { - setEditContent(entry.content ?? ""); setEditing(true); }; const cancelEdit = () => { setEditing(false); - setEditContent(""); }; const saveEdit = async () => { - const trimmed = editContent.trim(); + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); if (!trimmed) return; try { await onEdit(entry.id, trimmed); setEditing(false); - setEditContent(""); } catch { toast.error("Failed to update comment"); } @@ -242,17 +245,17 @@ function CommentCard({ {/* Header — always visible, acts as toggle */}
- + - + {getActorName(entry.actor_type, entry.actor_id)} + {timeAgo(entry.created_at)} } @@ -263,12 +266,12 @@ function CommentCard({ {!open && contentPreview && ( - - {contentPreview}{(entry.content ?? "").length > 80 ? "..." : ""} + + {contentPreview} )} {!open && replyCount > 0 && ( - + {replyCount} {replyCount === 1 ? "reply" : "replies"} )} @@ -315,27 +318,28 @@ function CommentCard({ {/* Parent comment body */}
{editing ? ( -
{ e.preventDefault(); saveEdit(); }} +
{ if (e.key === "Escape") cancelEdit(); }} > - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b border-border outline-none py-1" - onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} - /> -
- - +
+
- +
+ + +
+
) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( Promise; } -function CommentInput({ onSubmit }: CommentInputProps) { +function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); + const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); + const { uploadWithToast, uploading } = useFileUpload(); + + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + const result = await handleUpload(file); + if (result) { + editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/")); + } + }; const handleSubmit = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); @@ -35,16 +51,32 @@ function CommentInput({ onSubmit }: CommentInputProps) { placeholder="Leave a comment..." onUpdate={(md) => setIsEmpty(!md.trim())} onSubmit={handleSubmit} + onUploadFile={handleUpload} debounceMs={100} />
-
+
+ +
diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 53e44bb9..abe41e0b 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef, memo } from "react"; +import { useState, useEffect, useCallback, memo } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,8 +43,8 @@ import { DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; -import { Input } from "@/components/ui/input"; import { RichTextEditor } from "@/components/common/rich-text-editor"; +import { TitleEditor } from "@/components/common/title-editor"; import { Tooltip, TooltipTrigger, @@ -69,6 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; import { ReactionBar } from "@/components/common/reaction-bar"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -179,14 +180,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; const { getActorName, getActorInitials } = useActorName(); + const { uploadWithToast } = useFileUpload(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, }); const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [deleting, setDeleting] = useState(false); - const [titleDraft, setTitleDraft] = useState(""); - const titleFocusedRef = useRef(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); @@ -211,13 +211,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo .finally(() => setIssueLoading(false)); }, [id, !!issue]); - // Sync titleDraft when issue title changes (from WS or other views) - useEffect(() => { - if (issue && !titleFocusedRef.current) { - setTitleDraft(issue.title); - } - }, [issue?.title]); - // Custom hooks — encapsulate timeline, reactions, subscribers const { timeline, submitting, submitComment, submitReply, @@ -249,6 +242,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo [issue, id], ); + const handleDescriptionUpload = useCallback( + (file: File) => uploadWithToast(file, { issueId: id }), + [uploadWithToast, id], + ); + const handleDelete = async () => { setDeleting(true); try { @@ -547,26 +545,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Content — scrollable */}
- setTitleDraft(e.target.value)} - onFocus={() => { titleFocusedRef.current = true; }} - onBlur={() => { - titleFocusedRef.current = false; - const trimmed = titleDraft.trim(); + { + const trimmed = value.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); - else setTitleDraft(issue.title); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === "Escape") { - setTitleDraft(issue.title); - (e.target as HTMLInputElement).blur(); - } - }} - className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground" /> handleUpdateField({ description: md || undefined })} + onUploadFile={handleDescriptionUpload} debounceMs={1500} className="mt-5" /> @@ -741,6 +729,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo return ( - +
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b95662c4..4f7f8f31 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -1,16 +1,18 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2, Paperclip } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; import { ActorAvatar } from "@/components/common/actor-avatar"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; @@ -23,6 +25,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -30,8 +33,22 @@ function ReplyInput({ size = "default", }: ReplyInputProps) { const editorRef = useRef(null); + const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); + const { uploadWithToast, uploading } = useFileUpload(); + + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + const result = await handleUpload(file); + if (result) { + editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/")); + } + }; const handleSubmit = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); @@ -67,6 +84,7 @@ function ReplyInput({ placeholder={placeholder} onUpdate={(md) => setIsEmpty(!md.trim())} onSubmit={handleSubmit} + onUploadFile={handleUpload} debounceMs={100} />
@@ -76,14 +94,30 @@ function ReplyInput({ }`} >
-
+
+ +
diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index de211e0e..427b35a7 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -176,27 +176,14 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitComment = useCallback( async (content: string) => { if (!content.trim() || submitting || !userId) return; - const tempId = "temp-" + Date.now(); - const tempEntry: TimelineEntry = { - type: "comment", - id: tempId, - actor_type: "member", - actor_id: userId, - content, - parent_id: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - comment_type: "comment", - }; - setTimeline((prev) => [...prev, tempEntry]); setSubmitting(true); try { const comment = await api.createComment(issueId, content); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); @@ -208,26 +195,13 @@ export function useIssueTimeline(issueId: string, userId?: string) { const submitReply = useCallback( async (parentId: string, content: string) => { if (!content.trim() || !userId) return; - const tempId = "temp-" + Date.now(); - const tempEntry: TimelineEntry = { - type: "comment", - id: tempId, - actor_type: "member", - actor_id: userId, - content, - parent_id: parentId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - comment_type: "comment", - }; - setTimeline((prev) => [...prev, tempEntry]); try { const comment = await api.createComment(issueId, content, "comment", parentId); - setTimeline((prev) => - prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)), - ); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); } catch { - setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send reply"); } }, diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index abb0d0c6..d88e2b3a 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -24,8 +24,8 @@ import { import { Calendar } from "@/components/ui/calendar"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; +import { TitleEditor } from "@/components/common/title-editor"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; @@ -186,19 +186,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? {/* Title */}
- updateTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }} + defaultValue={draft.title} placeholder="Issue title" - className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0 dark:bg-transparent" + className="text-lg font-semibold" + onChange={(v) => updateTitle(v)} + onSubmit={handleSubmit} />
diff --git a/apps/web/features/realtime/provider.tsx b/apps/web/features/realtime/provider.tsx index 65928a2b..6381ba44 100644 --- a/apps/web/features/realtime/provider.tsx +++ b/apps/web/features/realtime/provider.tsx @@ -16,7 +16,11 @@ import { useWorkspaceStore } from "@/features/workspace"; import { createLogger } from "@/shared/logger"; import { useRealtimeSync } from "./use-realtime-sync"; -const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws"; +const WS_URL = + process.env.NEXT_PUBLIC_WS_URL || + (typeof window !== "undefined" + ? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws` + : "ws://localhost:8080/ws"); type EventHandler = (payload: unknown) => void; diff --git a/apps/web/features/workspace/hooks.ts b/apps/web/features/workspace/hooks.ts index 325dc46c..b1e062d9 100644 --- a/apps/web/features/workspace/hooks.ts +++ b/apps/web/features/workspace/hooks.ts @@ -32,5 +32,11 @@ export function useActorName() { .slice(0, 2); }; - return { getMemberName, getAgentName, getActorName, getActorInitials }; + const getActorAvatarUrl = (type: string, id: string): string | null => { + if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null; + if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null; + return null; + }; + + return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl }; } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index db0a3727..444f4090 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,24 @@ import type { NextConfig } from "next"; +const remoteApiUrl = process.env.REMOTE_API_URL ?? "https://multica-api.copilothub.ai"; + const nextConfig: NextConfig = { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${remoteApiUrl}/api/:path*`, + }, + { + source: "/ws", + destination: `${remoteApiUrl}/ws`, + }, + { + source: "/auth/:path*", + destination: `${remoteApiUrl}/auth/:path*`, + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 1237d3dc..f48cffa1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@floating-ui/dom": "^1.7.6", + "@tiptap/extension-image": "^3.20.5", "@tiptap/extension-link": "^3.20.5", "@tiptap/extension-mention": "^3.20.5", "@tiptap/extension-placeholder": "^3.20.5", diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index d2d2fcb3..2419d7bc 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -12,8 +12,6 @@ import type { UpdateAgentRequest, AgentTask, AgentRuntime, - DaemonPairingSession, - ApproveDaemonPairingSessionRequest, InboxItem, IssueSubscriber, Comment, @@ -35,6 +33,7 @@ import type { RuntimePing, TimelineEntry, TaskMessagePayload, + Attachment, } from "@/shared/types"; import { type Logger, noopLogger } from "@/shared/logger"; @@ -62,6 +61,35 @@ export class ApiClient { this.workspaceId = id; } + private authHeaders(): Record { + const headers: Record = {}; + if (this.token) headers["Authorization"] = `Bearer ${this.token}`; + if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + return headers; + } + + private handleUnauthorized() { + if (typeof window !== "undefined") { + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + this.token = null; + this.workspaceId = null; + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + } + + private async parseErrorMessage(res: Response, fallback: string): Promise { + try { + const data = await res.json() as { error?: string }; + if (typeof data.error === "string" && data.error) return data.error; + } catch { + // Ignore non-JSON error bodies. + } + return fallback; + } + private async fetch(path: string, init?: RequestInit): Promise { const rid = crypto.randomUUID().slice(0, 8); const start = Date.now(); @@ -70,42 +98,21 @@ export class ApiClient { const headers: Record = { "Content-Type": "application/json", "X-Request-ID": rid, + ...this.authHeaders(), ...((init?.headers as Record) ?? {}), }; - if (this.token) { - headers["Authorization"] = `Bearer ${this.token}`; - } - if (this.workspaceId) { - headers["X-Workspace-ID"] = this.workspaceId; - } this.logger.info(`→ ${method} ${path}`, { rid }); const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, + credentials: "include", }); if (!res.ok) { - if (res.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("multica_token"); - localStorage.removeItem("multica_workspace_id"); - this.token = null; - this.workspaceId = null; - if (window.location.pathname !== "/login") { - window.location.href = "/login"; - } - } - - let message = `API error: ${res.status} ${res.statusText}`; - try { - const data = await res.json() as { error?: string }; - if (typeof data.error === "string" && data.error) { - message = data.error; - } - } catch { - // Ignore non-JSON error bodies. - } + if (res.status === 401) this.handleUnauthorized(); + const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`); this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message }); throw new Error(message); } @@ -352,20 +359,6 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/task-runs`); } - async getDaemonPairingSession(token: string): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}`); - } - - async approveDaemonPairingSession( - token: string, - data: ApproveDaemonPairingSessionRequest, - ): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, { - method: "POST", - body: JSON.stringify(data), - }); - } - // Inbox async listInbox(): Promise { return this.fetch("/api/inbox"); @@ -519,4 +512,41 @@ export class ApiClient { async revokePersonalAccessToken(id: string): Promise { await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); } + + // File Upload & Attachments + async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise { + const formData = new FormData(); + formData.append("file", file); + if (opts?.issueId) formData.append("issue_id", opts.issueId); + if (opts?.commentId) formData.append("comment_id", opts.commentId); + + const rid = crypto.randomUUID().slice(0, 8); + const start = Date.now(); + this.logger.info("→ POST /api/upload-file", { rid }); + + const res = await fetch(`${this.baseUrl}/api/upload-file`, { + method: "POST", + headers: this.authHeaders(), + body: formData, + credentials: "include", + }); + + if (!res.ok) { + if (res.status === 401) this.handleUnauthorized(); + const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`); + this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message }); + throw new Error(message); + } + + this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` }); + return res.json() as Promise; + } + + async listAttachments(issueId: string): Promise { + return this.fetch(`/api/issues/${issueId}/attachments`); + } + + async deleteAttachment(id: string): Promise { + await this.fetch(`/api/attachments/${id}`, { method: "DELETE" }); + } } diff --git a/apps/web/shared/api/index.ts b/apps/web/shared/api/index.ts index 665e3153..af0b448b 100644 --- a/apps/web/shared/api/index.ts +++ b/apps/web/shared/api/index.ts @@ -5,7 +5,7 @@ export { ApiClient } from "./client"; export type { LoginResponse } from "./client"; export { WSClient } from "./ws-client"; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") }); diff --git a/apps/web/shared/hooks/use-file-upload.ts b/apps/web/shared/hooks/use-file-upload.ts new file mode 100644 index 00000000..ef5bafbe --- /dev/null +++ b/apps/web/shared/hooks/use-file-upload.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { api } from "@/shared/api"; +import type { Attachment } from "@/shared/types"; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + +const ALLOWED_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "application/pdf", + "text/plain", + "text/csv", + "application/json", + "video/mp4", + "video/webm", + "audio/mpeg", + "audio/wav", + "application/zip", +]); + +function isAllowedType(type: string): boolean { + // Empty MIME type (browser couldn't determine) — let the server sniff and decide. + if (!type) return true; + const mediaType = type.split(";")[0] ?? ""; + return ALLOWED_TYPES.has(mediaType.trim().toLowerCase()); +} + +export interface UploadResult { + filename: string; + link: string; +} + +export interface UploadContext { + issueId?: string; + commentId?: string; +} + +export function useFileUpload() { + const [uploading, setUploading] = useState(false); + + const upload = useCallback( + async (file: File, ctx?: UploadContext): Promise => { + if (file.size > MAX_FILE_SIZE) { + throw new Error("File exceeds 10 MB limit"); + } + if (!isAllowedType(file.type)) { + throw new Error(`File type not allowed: ${file.type}`); + } + + setUploading(true); + try { + const att: Attachment = await api.uploadFile(file, { + issueId: ctx?.issueId, + commentId: ctx?.commentId, + }); + return { filename: att.filename, link: att.url }; + } finally { + setUploading(false); + } + }, + [], + ); + + const uploadWithToast = useCallback( + async (file: File, ctx?: UploadContext): Promise => { + try { + return await upload(file, ctx); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed"); + return null; + } + }, + [upload], + ); + + return { upload, uploadWithToast, uploading }; +} diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts index 5dc2e9fa..d14cbebc 100644 --- a/apps/web/shared/types/activity.ts +++ b/apps/web/shared/types/activity.ts @@ -1,4 +1,5 @@ import type { Reaction } from "./comment"; +import type { Attachment } from "./attachment"; export interface TimelineEntry { type: "activity" | "comment"; @@ -15,4 +16,5 @@ export interface TimelineEntry { updated_at?: string; comment_type?: string; reactions?: Reaction[]; + attachments?: Attachment[]; } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts new file mode 100644 index 00000000..9908850c --- /dev/null +++ b/apps/web/shared/types/attachment.ts @@ -0,0 +1,14 @@ +export interface Attachment { + id: string; + workspace_id: string; + issue_id: string | null; + comment_id: string | null; + uploader_type: string; + uploader_id: string; + filename: string; + url: string; + download_url: string; + content_type: string; + size_bytes: number; + created_at: string; +} diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index bd2a4b57..c06c4f04 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -20,6 +20,7 @@ export interface Comment { type: CommentType; parent_id: string | null; reactions: Reaction[]; + attachments: import("./attachment").Attachment[]; created_at: string; updated_at: string; } diff --git a/apps/web/shared/types/daemon.ts b/apps/web/shared/types/daemon.ts deleted file mode 100644 index 459a67a5..00000000 --- a/apps/web/shared/types/daemon.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type DaemonPairingSessionStatus = "pending" | "approved" | "claimed" | "expired"; - -export interface DaemonPairingSession { - token: string; - daemon_id: string; - device_name: string; - runtime_name: string; - runtime_type: string; - runtime_version: string; - workspace_id: string | null; - status: DaemonPairingSessionStatus; - approved_at: string | null; - claimed_at: string | null; - expires_at: string; - created_at: string; - updated_at: string; - link_url?: string | null; -} - -export interface ApproveDaemonPairingSessionRequest { - workspace_id: string; -} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 5ef60118..709c7f18 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -27,6 +27,6 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment"; export type { TimelineEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; -export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; +export type { Attachment } from "./attachment"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2bc365f..2565644c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@floating-ui/dom': specifier: ^1.7.6 version: 1.7.6 + '@tiptap/extension-image': + specifier: ^3.20.5 + version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5)) '@tiptap/extension-link': specifier: ^3.20.5 version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5) @@ -1368,6 +1371,11 @@ packages: '@tiptap/core': ^3.20.5 '@tiptap/pm': ^3.20.5 + '@tiptap/extension-image@3.20.5': + resolution: {integrity: sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA==} + peerDependencies: + '@tiptap/core': ^3.20.5 + '@tiptap/extension-italic@3.20.5': resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==} peerDependencies: @@ -4949,6 +4957,10 @@ snapshots: '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5) '@tiptap/pm': 3.20.5 + '@tiptap/extension-image@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))': + dependencies: + '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5) + '@tiptap/extension-italic@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))': dependencies: '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5) diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 0443b37a..1ad0ed92 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "log/slog" - "regexp" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" @@ -13,15 +12,12 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) -// mention represents a parsed @mention from markdown content. +// mention represents a parsed @mention from markdown content (local alias). type mention struct { Type string // "member" or "agent" ID string // user_id or agent_id } -// mentionRe matches [@Label](mention://type/id) in markdown. -var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`) - // statusLabels maps DB status values to human-readable labels for notifications. var statusLabels = map[string]string{ "backlog": "Backlog", @@ -59,17 +55,12 @@ func priorityLabel(p string) string { var emptyDetails = []byte("{}") // parseMentions extracts mentions from markdown content. +// Delegates to the shared util.ParseMentions and converts to the local type. func parseMentions(content string) []mention { - matches := mentionRe.FindAllStringSubmatch(content, -1) - seen := make(map[string]bool) - var result []mention - for _, m := range matches { - key := m[1] + ":" + m[2] - if seen[key] { - continue - } - seen[key] = true - result = append(result, mention{Type: m[1], ID: m[2]}) + parsed := util.ParseMentions(content) + result := make([]mention, len(parsed)) + for i, m := range parsed { + result[i] = mention{Type: m.Type, ID: m.ID} } return result } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 0f70001d..400d3c40 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -12,11 +12,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" + "github.com/multica-ai/multica/server/internal/storage" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -47,7 +49,9 @@ func allowedOrigins() []string { func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router { queries := db.New(pool) emailSvc := service.NewEmailService() - h := handler.New(queries, pool, hub, bus, emailSvc) + s3 := storage.NewS3StorageFromEnv() + cfSigner := auth.NewCloudFrontSignerFromEnv() + h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner) r := chi.NewRouter() @@ -79,43 +83,37 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) - // Daemon API routes + // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { - // Pairing routes — no auth required (daemon doesn't have a token yet). - r.Post("/pairing-sessions", h.CreateDaemonPairingSession) - r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession) - r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) + r.Use(middleware.Auth(queries)) - // Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT. - r.Group(func(r chi.Router) { - r.Use(middleware.DaemonAuth(queries)) + r.Post("/register", h.DaemonRegister) + r.Post("/deregister", h.DaemonDeregister) + r.Post("/heartbeat", h.DaemonHeartbeat) - r.Post("/register", h.DaemonRegister) - r.Post("/deregister", h.DaemonDeregister) - r.Post("/heartbeat", h.DaemonHeartbeat) + r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) + r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) + r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) + r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) - r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) - r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime) - r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage) - r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult) - - r.Get("/tasks/{taskId}/status", h.GetTaskStatus) - r.Post("/tasks/{taskId}/start", h.StartTask) - r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) - r.Post("/tasks/{taskId}/complete", h.CompleteTask) - r.Post("/tasks/{taskId}/fail", h.FailTask) - r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) - r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) - }) + r.Get("/tasks/{taskId}/status", h.GetTaskStatus) + r.Post("/tasks/{taskId}/start", h.StartTask) + r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress) + r.Post("/tasks/{taskId}/complete", h.CompleteTask) + r.Post("/tasks/{taskId}/fail", h.FailTask) + r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) + r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) }) // Protected API routes r.Group(func(r chi.Router) { r.Use(middleware.Auth(queries)) + r.Use(middleware.RefreshCloudFrontCookies(cfSigner)) // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) r.Patch("/api/me", h.UpdateMe) + r.Post("/api/upload-file", h.UploadFile) r.Route("/api/workspaces", func(r chi.Router) { r.Get("/", h.ListWorkspaces) @@ -150,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Delete("/{id}", h.RevokePersonalAccessToken) }) - r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) - // --- Workspace-scoped routes (all require workspace membership) --- r.Group(func(r chi.Router) { r.Use(middleware.RequireWorkspaceMember(queries)) @@ -176,9 +172,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Get("/task-runs", h.ListTasksByIssue) r.Post("/reactions", h.AddIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction) + r.Get("/attachments", h.ListAttachments) }) }) + // Attachments + r.Delete("/api/attachments/{id}", h.DeleteAttachment) + // Comments r.Route("/api/comments/{commentId}", func(r chi.Router) { r.Put("/", h.UpdateComment) diff --git a/server/go.mod b/server/go.mod index 05c33813..30725711 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,21 +3,41 @@ module github.com/multica-ai/multica/server go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.13 + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 + github.com/lmittmann/tint v1.1.3 github.com/resend/resend-go/v2 v2.28.0 github.com/spf13/cobra v1.10.2 ) require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/lmittmann/tint v1.1.3 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/server/go.sum b/server/go.sum index da00dcd6..7017544c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,43 @@ +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= +github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go new file mode 100644 index 00000000..e4cccd6d --- /dev/null +++ b/server/internal/auth/cloudfront.go @@ -0,0 +1,203 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// CloudFrontSigner generates signed cookies for CloudFront private distributions. +type CloudFrontSigner struct { + keyPairID string + privateKey *rsa.PrivateKey + domain string // CDN domain, e.g. "static.multica.ai" + cookieDomain string // cookie scope, e.g. ".multica.ai" +} + +// NewCloudFrontSignerFromEnv creates a signer from environment variables. +// Returns nil if CLOUDFRONT_KEY_PAIR_ID is not set (disables signed cookies). +// +// Private key resolution order: +// 1. AWS Secrets Manager (CLOUDFRONT_PRIVATE_KEY_SECRET — secret name/ARN) +// 2. Environment variable fallback (CLOUDFRONT_PRIVATE_KEY — base64-encoded PEM, for local dev only) +// +// Other required environment variables: +// - CLOUDFRONT_KEY_PAIR_ID +// - CLOUDFRONT_DOMAIN (e.g. "static.multica.ai") +// - COOKIE_DOMAIN (e.g. ".multica.ai") +func NewCloudFrontSignerFromEnv() *CloudFrontSigner { + keyPairID := os.Getenv("CLOUDFRONT_KEY_PAIR_ID") + if keyPairID == "" { + slog.Info("CLOUDFRONT_KEY_PAIR_ID not set, signed cookies disabled") + return nil + } + + domain := os.Getenv("CLOUDFRONT_DOMAIN") + if domain == "" { + slog.Error("CLOUDFRONT_DOMAIN not set") + return nil + } + + cookieDomain := os.Getenv("COOKIE_DOMAIN") + if cookieDomain == "" { + slog.Error("COOKIE_DOMAIN not set") + return nil + } + + rsaKey, err := loadPrivateKey() + if err != nil { + slog.Error("failed to load CloudFront private key", "error", err) + return nil + } + + slog.Info("CloudFront cookie signer initialized", "key_pair_id", keyPairID, "domain", domain) + return &CloudFrontSigner{ + keyPairID: keyPairID, + privateKey: rsaKey, + domain: domain, + cookieDomain: cookieDomain, + } +} + +// loadPrivateKey loads the RSA private key from Secrets Manager or env var fallback. +func loadPrivateKey() (*rsa.PrivateKey, error) { + // 1. Try Secrets Manager + if secretName := os.Getenv("CLOUDFRONT_PRIVATE_KEY_SECRET"); secretName != "" { + slog.Info("loading CloudFront private key from Secrets Manager", "secret", secretName) + return loadKeyFromSecretsManager(secretName) + } + + // 2. Fallback: base64-encoded env var (local dev) + if pkB64 := os.Getenv("CLOUDFRONT_PRIVATE_KEY"); pkB64 != "" { + slog.Info("loading CloudFront private key from environment variable (local dev)") + pemBytes, err := base64.StdEncoding.DecodeString(pkB64) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + return parseRSAPrivateKey(pemBytes) + } + + return nil, fmt.Errorf("neither CLOUDFRONT_PRIVATE_KEY_SECRET nor CLOUDFRONT_PRIVATE_KEY is set") +} + +func loadKeyFromSecretsManager(secretName string) (*rsa.PrivateKey, error) { + cfg, err := awsconfig.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("load AWS config: %w", err) + } + + client := secretsmanager.NewFromConfig(cfg) + result, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + }) + if err != nil { + return nil, fmt.Errorf("get secret %q: %w", secretName, err) + } + + if result.SecretString == nil { + return nil, fmt.Errorf("secret %q has no string value", secretName) + } + + return parseRSAPrivateKey([]byte(*result.SecretString)) +} + +func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + + // Try PKCS8 first, then PKCS1 + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + return nil, fmt.Errorf("PKCS8 key is not RSA") + } + + rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return rsaKey, nil +} + +// SignedCookies generates the three CloudFront signed cookies with the given expiry. +func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { + policy := fmt.Sprintf(`{"Statement":[{"Resource":"https://%s/*","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, s.domain, expiry.Unix()) + + encodedPolicy := cfBase64Encode([]byte(policy)) + + h := sha1.New() + h.Write([]byte(policy)) + sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil)) + if err != nil { + slog.Error("failed to sign CloudFront policy", "error", err) + return nil + } + encodedSig := cfBase64Encode(sig) + + cookieAttrs := func(name, value string) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Domain: s.cookieDomain, + Path: "/", + Expires: expiry, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + } + } + + return []*http.Cookie{ + cookieAttrs("CloudFront-Policy", encodedPolicy), + cookieAttrs("CloudFront-Signature", encodedSig), + cookieAttrs("CloudFront-Key-Pair-Id", s.keyPairID), + } +} + +// SignedURL generates a CloudFront signed URL for the given resource URL. +// Used by CLI/API clients that don't have browser cookies. +func (s *CloudFrontSigner) SignedURL(rawURL string, expiry time.Time) string { + policy := fmt.Sprintf(`{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, rawURL, expiry.Unix()) + + encodedPolicy := cfBase64Encode([]byte(policy)) + + h := sha1.New() + h.Write([]byte(policy)) + sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil)) + if err != nil { + slog.Error("failed to sign CloudFront URL", "error", err) + return rawURL + } + encodedSig := cfBase64Encode(sig) + + separator := "?" + if strings.Contains(rawURL, "?") { + separator = "&" + } + return fmt.Sprintf("%s%sPolicy=%s&Signature=%s&Key-Pair-Id=%s", rawURL, separator, encodedPolicy, encodedSig, s.keyPairID) +} + +// cfBase64Encode applies CloudFront's URL-safe base64 encoding. +func cfBase64Encode(data []byte) string { + encoded := base64.StdEncoding.EncodeToString(data) + r := strings.NewReplacer("+", "-", "=", "_", "/", "~") + return r.Replace(encoded) +} diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index 5430b78a..b73810eb 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -25,11 +25,12 @@ type TimelineEntry struct { Details json.RawMessage `json:"details,omitempty"` // Comment-only fields - Content *string `json:"content,omitempty"` - ParentID *string `json:"parent_id,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - CommentType *string `json:"comment_type,omitempty"` - Reactions []ReactionResponse `json:"reactions,omitempty"` + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + Reactions []ReactionResponse `json:"reactions,omitempty"` + Attachments []AttachmentResponse `json:"attachments,omitempty"` } // ListTimeline returns a merged, chronologically-sorted timeline of activities @@ -79,20 +80,22 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { }) } - // Fetch reactions for all comments in one batch. + // Fetch reactions and attachments for all comments in one batch. commentIDs := make([]pgtype.UUID, len(comments)) for i, c := range comments { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) for _, c := range comments { content := c.Content commentType := c.Type updatedAt := timestampToString(c.UpdatedAt) + cid := uuidToString(c.ID) timeline = append(timeline, TimelineEntry{ Type: "comment", - ID: uuidToString(c.ID), + ID: cid, ActorType: c.AuthorType, ActorID: uuidToString(c.AuthorID), Content: &content, @@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: &updatedAt, - Reactions: grouped[uuidToString(c.ID)], + Reactions: grouped[cid], + Attachments: groupedAtt[cid], }) } diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index cc2572cb..d2bab8eb 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -328,24 +328,23 @@ type UpdateAgentRequest struct { } // canManageAgent checks whether the current user can update or delete an agent. -// Workspace-visible agents require owner/admin role. Private agents additionally -// require the user to be the agent's owner (or a workspace owner/admin). +// Workspace-visible agents can be managed by any workspace member. +// Private agents can only be managed by their owner or workspace owner/admin. func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool { wsID := uuidToString(agent.WorkspaceID) member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member") if !ok { return false } + if agent.Visibility != "private" { + return true + } isAdmin := roleAllowed(member.Role, "owner", "admin") isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r) - if agent.Visibility == "private" && !isAdmin && !isAgentOwner { + if !isAdmin && !isAgentOwner { writeError(w, http.StatusForbidden, "only the agent owner can manage this private agent") return false } - if agent.Visibility != "private" && !isAdmin && !isAgentOwner { - writeError(w, http.StatusForbidden, "insufficient permissions") - return false - } return true } diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index e073ef82..5339190c 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -300,6 +300,13 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { return } + // Set CloudFront signed cookies for CDN access. + if h.CFSigner != nil { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) writeJSON(w, http.StatusOK, LoginResponse{ Token: tokenString, diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..f05c07df 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "log/slog" "net/http" @@ -8,38 +9,44 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) type CommentResponse struct { - ID string `json:"id"` - IssueID string `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID string `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - ParentID *string `json:"parent_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Reactions []ReactionResponse `json:"reactions"` + ID string `json:"id"` + IssueID string `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Reactions []ReactionResponse `json:"reactions"` + Attachments []AttachmentResponse `json:"attachments"` } -func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse { +func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse { if reactions == nil { reactions = []ReactionResponse{} } + if attachments == nil { + attachments = []AttachmentResponse{} + } return CommentResponse{ - ID: uuidToString(c.ID), - IssueID: uuidToString(c.IssueID), - AuthorType: c.AuthorType, - AuthorID: uuidToString(c.AuthorID), - Content: c.Content, - Type: c.Type, - ParentID: uuidToPtr(c.ParentID), - CreatedAt: timestampToString(c.CreatedAt), - UpdatedAt: timestampToString(c.UpdatedAt), - Reactions: reactions, + ID: uuidToString(c.ID), + IssueID: uuidToString(c.IssueID), + AuthorType: c.AuthorType, + AuthorID: uuidToString(c.AuthorID), + Content: c.Content, + Type: c.Type, + ParentID: uuidToPtr(c.ParentID), + CreatedAt: timestampToString(c.CreatedAt), + UpdatedAt: timestampToString(c.UpdatedAt), + Reactions: reactions, + Attachments: attachments, } } @@ -64,10 +71,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) resp := make([]CommentResponse, len(comments)) for i, c := range comments { - resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)]) + cid := uuidToString(c.ID) + resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid]) } writeJSON(w, http.StatusOK, resp) @@ -133,7 +142,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment, nil) + resp := commentToResponse(comment, nil, nil) slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...) h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{ "comment": resp, @@ -145,7 +154,10 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { // If the issue is assigned to an agent with on_comment trigger, enqueue a new task. // Skip when the comment comes from the assigned agent itself to avoid loops. - if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) { + // Also skip when the comment @mentions others but not the assignee agent — + // the user is talking to someone else, not requesting work from the assignee. + if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) && + !h.commentMentionsOthersButNotAssignee(comment.Content, issue) { // Resolve thread root: if the comment is a reply, agent should reply // to the thread root (matching frontend behavior where all replies // in a thread share the same top-level parent). @@ -158,9 +170,82 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { } } + // Trigger @mentioned agents: parse agent mentions and enqueue tasks for each. + h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID) + writeJSON(w, http.StatusCreated, resp) } +// commentMentionsOthersButNotAssignee returns true if the comment @mentions +// anyone but does NOT @mention the issue's assignee agent. This is used to +// suppress the on_comment trigger when the user is directing their comment at +// someone else (e.g. sharing results with a colleague, asking another agent). +func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool { + mentions := util.ParseMentions(content) + if len(mentions) == 0 { + return false // No mentions — normal on_comment behavior + } + if !issue.AssigneeID.Valid { + return true // No assignee — mentions target others + } + assigneeID := uuidToString(issue.AssigneeID) + for _, m := range mentions { + if m.ID == assigneeID { + return false // Assignee is mentioned — allow trigger + } + } + return true // Others mentioned but not assignee — suppress trigger +} + +// enqueueMentionedAgentTasks parses @agent mentions from comment content and +// enqueues a task for each mentioned agent. Skips self-mentions, agents that +// are already the issue's assignee (handled by on_comment), and agents with +// on_mention trigger disabled. +func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) { + // Don't trigger on terminal statuses. + if issue.Status == "done" || issue.Status == "cancelled" { + return + } + + mentions := util.ParseMentions(comment.Content) + for _, m := range mentions { + if m.Type != "agent" { + continue + } + // Prevent self-trigger: skip if the comment author is this agent. + if authorType == "agent" && authorID == m.ID { + continue + } + agentUUID := parseUUID(m.ID) + // Prevent duplicate: skip if this agent is the issue's assignee + // (already handled by the on_comment trigger above). + if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" && + issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID { + continue + } + // Check if the agent has on_mention trigger enabled. + if !h.isAgentMentionTriggerEnabled(ctx, agentUUID) { + continue + } + // Dedup: skip if this agent already has a pending task for this issue. + hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{ + IssueID: issue.ID, + AgentID: agentUUID, + }) + if err != nil || hasPending { + continue + } + // Resolve thread root for reply threading. + replyTo := comment.ID + if comment.ParentID.Valid { + replyTo = comment.ParentID + } + if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, replyTo); err != nil { + slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err) + } + } +} + func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { commentId := chi.URLParam(r, "commentId") @@ -215,9 +300,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - // Fetch reactions for the updated comment. + // Fetch reactions and attachments for the updated comment. grouped := h.groupReactions(r, []pgtype.UUID{comment.ID}) - resp := commentToResponse(comment, grouped[uuidToString(comment.ID)]) + groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID}) + cid := uuidToString(comment.ID) + resp := commentToResponse(comment, grouped[cid], groupedAtt[cid]) slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...) h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d91b9376..ae7803c4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -53,6 +53,12 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "at least one runtime is required") return } + + // Verify the caller is a member of the target workspace. + if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok { + return + } + ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go deleted file mode 100644 index a8bfcfda..00000000 --- a/server/internal/handler/daemon_pairing.go +++ /dev/null @@ -1,424 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/multica-ai/multica/server/internal/auth" - db "github.com/multica-ai/multica/server/pkg/db/generated" -) - -const daemonPairingTTL = 10 * time.Minute - -type daemonPairingSessionRecord struct { - Token string - DaemonID string - DeviceName string - RuntimeName string - RuntimeType string - RuntimeVersion string - WorkspaceID pgtype.UUID - ApprovedBy pgtype.UUID - Status string - ApprovedAt pgtype.Timestamptz - ClaimedAt pgtype.Timestamptz - ExpiresAt pgtype.Timestamptz - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz -} - -type DaemonPairingSessionResponse struct { - Token string `json:"token"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` - WorkspaceID *string `json:"workspace_id"` - Status string `json:"status"` - ApprovedAt *string `json:"approved_at"` - ClaimedAt *string `json:"claimed_at"` - ExpiresAt string `json:"expires_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - LinkURL *string `json:"link_url,omitempty"` - DaemonToken *string `json:"daemon_token,omitempty"` -} - -type CreateDaemonPairingSessionRequest struct { - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` -} - -type ApproveDaemonPairingSessionRequest struct { - WorkspaceID string `json:"workspace_id"` -} - -func daemonAppBaseURL() string { - for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return strings.TrimRight(value, "/") - } - } - return "http://localhost:3000" -} - -func daemonPairingLinkURL(token string) string { - base := daemonAppBaseURL() - return base + "/pair/local?token=" + url.QueryEscape(token) -} - -func daemonPairingSessionToResponse(rec daemonPairingSessionRecord, includeLink bool) DaemonPairingSessionResponse { - resp := DaemonPairingSessionResponse{ - Token: rec.Token, - DaemonID: rec.DaemonID, - DeviceName: rec.DeviceName, - RuntimeName: rec.RuntimeName, - RuntimeType: rec.RuntimeType, - RuntimeVersion: rec.RuntimeVersion, - WorkspaceID: uuidToPtr(rec.WorkspaceID), - Status: rec.Status, - ApprovedAt: timestampToPtr(rec.ApprovedAt), - ClaimedAt: timestampToPtr(rec.ClaimedAt), - ExpiresAt: timestampToString(rec.ExpiresAt), - CreatedAt: timestampToString(rec.CreatedAt), - UpdatedAt: timestampToString(rec.UpdatedAt), - } - if includeLink { - link := daemonPairingLinkURL(rec.Token) - resp.LinkURL = &link - } - return resp -} - -func randomDaemonPairingToken() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -func (h *Handler) getDaemonPairingSession(ctx context.Context, token string) (daemonPairingSessionRecord, error) { - if h.DB == nil { - return daemonPairingSessionRecord{}, fmt.Errorf("database executor is not configured") - } - - var rec daemonPairingSessionRecord - err := h.DB.QueryRow(ctx, ` - SELECT - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - workspace_id, - approved_by, - status, - approved_at, - claimed_at, - expires_at, - created_at, - updated_at - FROM daemon_pairing_session - WHERE token = $1 - `, token).Scan( - &rec.Token, - &rec.DaemonID, - &rec.DeviceName, - &rec.RuntimeName, - &rec.RuntimeType, - &rec.RuntimeVersion, - &rec.WorkspaceID, - &rec.ApprovedBy, - &rec.Status, - &rec.ApprovedAt, - &rec.ClaimedAt, - &rec.ExpiresAt, - &rec.CreatedAt, - &rec.UpdatedAt, - ) - if err != nil { - return daemonPairingSessionRecord{}, err - } - - if rec.Status == "pending" && rec.ExpiresAt.Valid && rec.ExpiresAt.Time.Before(time.Now()) { - if _, err := h.DB.Exec(ctx, ` - UPDATE daemon_pairing_session - SET status = 'expired', updated_at = now() - WHERE token = $1 AND status = 'pending' - `, token); err == nil { - rec.Status = "expired" - rec.UpdatedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true} - } - } - - return rec, nil -} - -func (h *Handler) CreateDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - var req CreateDaemonPairingSessionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - req.DaemonID = strings.TrimSpace(req.DaemonID) - req.DeviceName = strings.TrimSpace(req.DeviceName) - req.RuntimeName = strings.TrimSpace(req.RuntimeName) - req.RuntimeType = strings.TrimSpace(req.RuntimeType) - req.RuntimeVersion = strings.TrimSpace(req.RuntimeVersion) - - if req.DaemonID == "" { - writeError(w, http.StatusBadRequest, "daemon_id is required") - return - } - if req.DeviceName == "" { - writeError(w, http.StatusBadRequest, "device_name is required") - return - } - if req.RuntimeName == "" { - writeError(w, http.StatusBadRequest, "runtime_name is required") - return - } - if req.RuntimeType == "" { - writeError(w, http.StatusBadRequest, "runtime_type is required") - return - } - - token, err := randomDaemonPairingToken() - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create pairing token") - return - } - - expiresAt := time.Now().Add(daemonPairingTTL) - var rec daemonPairingSessionRecord - err = h.DB.QueryRow(r.Context(), ` - INSERT INTO daemon_pairing_session ( - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - expires_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - workspace_id, - approved_by, - status, - approved_at, - claimed_at, - expires_at, - created_at, - updated_at - `, - token, - req.DaemonID, - req.DeviceName, - req.RuntimeName, - req.RuntimeType, - req.RuntimeVersion, - expiresAt, - ).Scan( - &rec.Token, - &rec.DaemonID, - &rec.DeviceName, - &rec.RuntimeName, - &rec.RuntimeType, - &rec.RuntimeVersion, - &rec.WorkspaceID, - &rec.ApprovedBy, - &rec.Status, - &rec.ApprovedAt, - &rec.ClaimedAt, - &rec.ExpiresAt, - &rec.CreatedAt, - &rec.UpdatedAt, - ) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create pairing session") - return - } - - writeJSON(w, http.StatusCreated, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) GetDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) ApproveDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - if rec.Status == "expired" { - writeError(w, http.StatusBadRequest, "pairing session expired") - return - } - if rec.Status == "claimed" { - writeError(w, http.StatusBadRequest, "pairing session already claimed") - return - } - if rec.Status == "approved" { - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) - return - } - - var req ApproveDaemonPairingSessionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if req.WorkspaceID == "" { - writeError(w, http.StatusBadRequest, "workspace_id is required") - return - } - - userID, ok := requireUserID(w, r) - if !ok { - return - } - if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok { - return - } - - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - if _, err := h.DB.Exec(r.Context(), ` - UPDATE daemon_pairing_session - SET - workspace_id = $2, - approved_by = $3, - status = 'approved', - approved_at = now(), - updated_at = now() - WHERE token = $1 AND status = 'pending' - `, token, parseUUID(req.WorkspaceID), parseUUID(userID)); err != nil { - writeError(w, http.StatusInternalServerError, "failed to approve pairing session") - return - } - - rec, err = h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to reload pairing session") - return - } - - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - if rec.Status == "claimed" { - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) - return - } - if rec.Status != "approved" { - writeError(w, http.StatusBadRequest, "pairing session is not approved") - return - } - - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - if _, err := h.DB.Exec(r.Context(), ` - UPDATE daemon_pairing_session - SET - status = 'claimed', - claimed_at = now(), - updated_at = now() - WHERE token = $1 AND status = 'approved' - `, token); err != nil { - writeError(w, http.StatusInternalServerError, "failed to claim pairing session") - return - } - - rec, err = h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to reload pairing session") - return - } - - resp := daemonPairingSessionToResponse(rec, true) - - // Issue a daemon auth token bound to the workspace and daemon. - if rec.WorkspaceID.Valid { - plainToken, err := auth.GenerateDaemonToken() - if err != nil { - slog.Error("failed to generate daemon token", "error", err) - writeError(w, http.StatusInternalServerError, "failed to generate daemon token") - return - } - hash := auth.HashToken(plainToken) - - // Revoke any existing tokens for this workspace+daemon pair. - _ = h.Queries.DeleteDaemonTokensByWorkspaceAndDaemon(r.Context(), db.DeleteDaemonTokensByWorkspaceAndDaemonParams{ - WorkspaceID: rec.WorkspaceID, - DaemonID: rec.DaemonID, - }) - - _, err = h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{ - TokenHash: hash, - WorkspaceID: rec.WorkspaceID, - DaemonID: rec.DaemonID, - ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(365 * 24 * time.Hour), Valid: true}, - }) - if err != nil { - slog.Error("failed to store daemon token", "error", err) - writeError(w, http.StatusInternalServerError, "failed to store daemon token") - return - } - - resp.DaemonToken = &plainToken - slog.Info("daemon token issued", "daemon_id", rec.DaemonID, "workspace_id", uuidToPtr(rec.WorkspaceID)) - } - - writeJSON(w, http.StatusOK, resp) -} diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go new file mode 100644 index 00000000..50bedc9d --- /dev/null +++ b/server/internal/handler/file.go @@ -0,0 +1,296 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +const maxUploadSize = 10 << 20 // 10 MB + +// Allowed MIME type prefixes and exact types for uploads. +var allowedContentTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/json": true, + "video/mp4": true, + "video/webm": true, + "audio/mpeg": true, + "audio/wav": true, + "application/zip": true, +} + +func isContentTypeAllowed(ct string) bool { + // Normalize: take only the media type, strip parameters like charset. + ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]) + ct = strings.ToLower(ct) + return allowedContentTypes[ct] +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +type AttachmentResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + IssueID *string `json:"issue_id"` + CommentID *string `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID string `json:"uploader_id"` + Filename string `json:"filename"` + URL string `json:"url"` + DownloadURL string `json:"download_url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse { + resp := AttachmentResponse{ + ID: uuidToString(a.ID), + WorkspaceID: uuidToString(a.WorkspaceID), + UploaderType: a.UploaderType, + UploaderID: uuidToString(a.UploaderID), + Filename: a.Filename, + URL: a.Url, + DownloadURL: a.Url, + ContentType: a.ContentType, + SizeBytes: a.SizeBytes, + CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + } + if h.CFSigner != nil { + resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute)) + } + if a.IssueID.Valid { + s := uuidToString(a.IssueID) + resp.IssueID = &s + } + if a.CommentID.Valid { + s := uuidToString(a.CommentID) + resp.CommentID = &s + } + return resp +} + +// groupAttachments loads attachments for multiple comments and groups them by comment ID. +func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse { + if len(commentIDs) == 0 { + return nil + } + attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs) + if err != nil { + slog.Error("failed to load attachments for comments", "error", err) + return nil + } + grouped := make(map[string][]AttachmentResponse, len(commentIDs)) + for _, a := range attachments { + cid := uuidToString(a.CommentID) + grouped[cid] = append(grouped[cid], h.attachmentToResponse(a)) + } + return grouped +} + +// --------------------------------------------------------------------------- +// UploadFile — POST /api/upload-file +// --------------------------------------------------------------------------- + +func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { + if h.Storage == nil { + writeError(w, http.StatusServiceUnavailable, "file upload not configured") + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + workspaceID := resolveWorkspaceID(r) + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + writeError(w, http.StatusBadRequest, "file too large or invalid multipart form") + return + } + defer r.MultipartForm.RemoveAll() + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err)) + return + } + defer file.Close() + + // Sniff actual content type from file bytes instead of trusting the client header. + buf := make([]byte, 512) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + writeError(w, http.StatusBadRequest, "failed to read file") + return + } + contentType := http.DetectContentType(buf[:n]) + if !isContentTypeAllowed(contentType) { + writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType)) + return + } + // Seek back so the full file is uploaded. + if _, err := file.Seek(0, io.SeekStart); err != nil { + writeError(w, http.StatusInternalServerError, "failed to read file") + return + } + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read file") + return + } + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + slog.Error("failed to generate file key", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + key := hex.EncodeToString(b) + path.Ext(header.Filename) + + link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) + if err != nil { + slog.Error("file upload failed", "error", err) + writeError(w, http.StatusInternalServerError, "upload failed") + return + } + + // If workspace context is available, create an attachment record. + if workspaceID != "" { + uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID) + + params := db.CreateAttachmentParams{ + WorkspaceID: parseUUID(workspaceID), + UploaderType: uploaderType, + UploaderID: parseUUID(uploaderID), + Filename: header.Filename, + Url: link, + ContentType: contentType, + SizeBytes: int64(len(data)), + } + + // Optional issue_id / comment_id from form fields + if issueID := r.FormValue("issue_id"); issueID != "" { + params.IssueID = parseUUID(issueID) + } + if commentID := r.FormValue("comment_id"); commentID != "" { + params.CommentID = parseUUID(commentID) + } + + att, err := h.Queries.CreateAttachment(r.Context(), params) + if err != nil { + slog.Error("failed to create attachment record", "error", err) + // S3 upload succeeded but DB record failed — still return the link + // so the file is usable. Log the error for investigation. + } else { + writeJSON(w, http.StatusOK, h.attachmentToResponse(att)) + return + } + } + + // Fallback response (no workspace context, e.g. avatar upload) + writeJSON(w, http.StatusOK, map[string]string{ + "filename": header.Filename, + "link": link, + }) +} + +// --------------------------------------------------------------------------- +// ListAttachments — GET /api/issues/{id}/attachments +// --------------------------------------------------------------------------- + +func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { + issueID := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + + attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if err != nil { + slog.Error("failed to list attachments", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list attachments") + return + } + + resp := make([]AttachmentResponse, len(attachments)) + for i, a := range attachments { + resp[i] = h.attachmentToResponse(a) + } + writeJSON(w, http.StatusOK, resp) +} + +// --------------------------------------------------------------------------- +// DeleteAttachment — DELETE /api/attachments/{id} +// --------------------------------------------------------------------------- + +func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { + attachmentID := chi.URLParam(r, "id") + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{ + ID: parseUUID(attachmentID), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "attachment not found") + return + } + + // Only the uploader (or workspace admin) can delete + uploaderID := uuidToString(att.UploaderID) + isUploader := att.UploaderType == "member" && uploaderID == userID + member, hasMember := ctxMember(r.Context()) + isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner") + + if !isUploader && !isAdmin { + writeError(w, http.StatusForbidden, "not authorized to delete this attachment") + return + } + + if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{ + ID: att.ID, + WorkspaceID: att.WorkspaceID, + }); err != nil { + slog.Error("failed to delete attachment", "error", err) + writeError(w, http.StatusInternalServerError, "failed to delete attachment") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index d8086a79..cdf81027 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -12,10 +12,12 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" + "github.com/multica-ai/multica/server/internal/storage" "github.com/multica-ai/multica/server/internal/util" ) @@ -38,9 +40,11 @@ type Handler struct { TaskService *service.TaskService EmailService *service.EmailService PingStore *PingStore + Storage *storage.S3Storage + CFSigner *auth.CloudFrontSigner } -func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler { +func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler { var executor dbExecutor if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate @@ -55,6 +59,8 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event TaskService: service.NewTaskService(queries, hub, bus), EmailService: emailService, PingStore: NewPingStore(), + Storage: s3, + CFSigner: cfSigner, } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index daf68e0a..9a5ec3c8 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -53,7 +53,7 @@ func TestMain(m *testing.M) { go hub.Run() bus := events.New() emailSvc := service.NewEmailService() - testHandler = New(queries, pool, hub, bus, emailSvc) + testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil) testPool = pool testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool) @@ -729,6 +729,7 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) { "runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}] }`)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", testUserID) testHandler.DaemonRegister(w, req) if w.Code != http.StatusNotFound { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index bb24fcd7..ca3d9bd8 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -515,6 +515,30 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri return false } +// isAgentMentionTriggerEnabled checks if a specific agent has the on_mention +// trigger enabled. Unlike isAgentTriggerEnabled, this takes an explicit agent +// ID rather than deriving it from the issue assignee. +func (h *Handler) isAgentMentionTriggerEnabled(ctx context.Context, agentID pgtype.UUID) bool { + agent, err := h.Queries.GetAgent(ctx, agentID) + if err != nil || !agent.RuntimeID.Valid { + return false + } + if agent.Triggers == nil || len(agent.Triggers) == 0 { + return true // No config = all triggers enabled by default + } + + var triggers []agentTriggerSnapshot + if err := json.Unmarshal(agent.Triggers, &triggers); err != nil { + return false + } + for _, trigger := range triggers { + if trigger.Type == "on_mention" { + return trigger.Enabled + } + } + return true // on_mention not configured = enabled by default +} + func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") issue, ok := h.loadIssueForUser(w, r, id) diff --git a/server/internal/middleware/cloudfront.go b/server/internal/middleware/cloudfront.go new file mode 100644 index 00000000..ab749998 --- /dev/null +++ b/server/internal/middleware/cloudfront.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/multica-ai/multica/server/internal/auth" +) + +// RefreshCloudFrontCookies is middleware that refreshes CloudFront signed cookies +// on authenticated requests when the cookie is missing (expired or first request +// after login). This prevents 403s from the CDN when cookies expire before the +// user's session does. +func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if signer == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := r.Cookie("CloudFront-Policy"); err != nil { + for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/internal/service/task.go b/server/internal/service/task.go index b2574aba..b09d56d3 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -69,6 +69,36 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t return task, nil } +// EnqueueTaskForMention creates a queued task for a mentioned agent on an issue. +// Unlike EnqueueTaskForIssue, this takes an explicit agent ID rather than +// deriving it from the issue assignee. +func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) { + agent, err := s.Queries.GetAgent(ctx, agentID) + if err != nil { + slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err) + return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err) + } + if !agent.RuntimeID.Valid { + slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID)) + return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime") + } + + task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{ + AgentID: agentID, + RuntimeID: agent.RuntimeID, + IssueID: issue.ID, + Priority: priorityToInt(issue.Priority), + TriggerCommentID: triggerCommentID, + }) + if err != nil { + slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err) + return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err) + } + + slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID)) + return task, nil +} + // CancelTasksForIssue cancels all active tasks for an issue. func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error { return s.Queries.CancelAgentTasksByIssue(ctx, issueID) diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go new file mode 100644 index 00000000..86167c18 --- /dev/null +++ b/server/internal/storage/s3.go @@ -0,0 +1,107 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +type S3Storage struct { + client *s3.Client + bucket string + cdnDomain string // if set, returned URLs use this instead of bucket name +} + +// NewS3StorageFromEnv creates an S3Storage from environment variables. +// Returns nil if S3_BUCKET is not set. +// +// Environment variables: +// - S3_BUCKET (required) +// - S3_REGION (default: us-west-2) +// - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optional; falls back to default credential chain) +func NewS3StorageFromEnv() *S3Storage { + bucket := os.Getenv("S3_BUCKET") + if bucket == "" { + slog.Info("S3_BUCKET not set, file upload disabled") + return nil + } + + region := os.Getenv("S3_REGION") + if region == "" { + region = "us-west-2" + } + + opts := []func(*config.LoadOptions) error{ + config.WithRegion(region), + } + + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + if accessKey != "" && secretKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + )) + } + + cfg, err := config.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + slog.Error("failed to load AWS config", "error", err) + return nil + } + + cdnDomain := os.Getenv("CLOUDFRONT_DOMAIN") + + slog.Info("S3 storage initialized", "bucket", bucket, "region", region, "cdn_domain", cdnDomain) + return &S3Storage{ + client: s3.NewFromConfig(cfg), + bucket: bucket, + cdnDomain: cdnDomain, + } +} + +// sanitizeFilename removes characters that could cause header injection in Content-Disposition. +func sanitizeFilename(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Strip control chars, newlines, null bytes, quotes, semicolons, backslashes + if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' { + b.WriteRune('_') + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) { + safe := sanitizeFilename(filename) + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)), + CacheControl: aws.String("max-age=432000,public"), + StorageClass: types.StorageClassIntelligentTiering, + }) + if err != nil { + return "", fmt.Errorf("s3 PutObject: %w", err) + } + + domain := s.bucket + if s.cdnDomain != "" { + domain = s.cdnDomain + } + link := fmt.Sprintf("https://%s/%s", domain, key) + return link, nil +} diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go new file mode 100644 index 00000000..83249b2c --- /dev/null +++ b/server/internal/util/mention.go @@ -0,0 +1,28 @@ +package util + +import "regexp" + +// Mention represents a parsed @mention from markdown content. +type Mention struct { + Type string // "member" or "agent" + ID string // user_id or agent_id +} + +// MentionRe matches [@Label](mention://type/id) in markdown. +var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`) + +// ParseMentions extracts deduplicated mentions from markdown content. +func ParseMentions(content string) []Mention { + matches := MentionRe.FindAllStringSubmatch(content, -1) + seen := make(map[string]bool) + var result []Mention + for _, m := range matches { + key := m[1] + ":" + m[2] + if seen[key] { + continue + } + seen[key] = true + result = append(result, Mention{Type: m[1], ID: m[2]}) + } + return result +} diff --git a/server/migrations/029_attachment.down.sql b/server/migrations/029_attachment.down.sql new file mode 100644 index 00000000..4e5f6d4f --- /dev/null +++ b/server/migrations/029_attachment.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS attachment; diff --git a/server/migrations/029_attachment.up.sql b/server/migrations/029_attachment.up.sql new file mode 100644 index 00000000..225c373a --- /dev/null +++ b/server/migrations/029_attachment.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE attachment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + issue_id UUID REFERENCES issue(id) ON DELETE CASCADE, + comment_id UUID REFERENCES comment(id) ON DELETE CASCADE, + uploader_type TEXT NOT NULL CHECK (uploader_type IN ('member', 'agent')), + uploader_id UUID NOT NULL, + filename TEXT NOT NULL, + url TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_attachment_issue ON attachment(issue_id) WHERE issue_id IS NOT NULL; +CREATE INDEX idx_attachment_comment ON attachment(comment_id) WHERE comment_id IS NOT NULL; +CREATE INDEX idx_attachment_workspace ON attachment(workspace_id); diff --git a/server/migrations/029_drop_daemon_pairing.down.sql b/server/migrations/029_drop_daemon_pairing.down.sql new file mode 100644 index 00000000..c39431e7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.down.sql @@ -0,0 +1,21 @@ +-- Re-create the daemon_pairing_session table (from migration 005). +CREATE TABLE IF NOT EXISTS daemon_pairing_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, + daemon_id TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + runtime_name TEXT NOT NULL DEFAULT '', + runtime_type TEXT NOT NULL DEFAULT '', + runtime_version TEXT NOT NULL DEFAULT '', + workspace_id UUID REFERENCES workspace(id), + approved_by UUID REFERENCES "user"(id), + status TEXT NOT NULL DEFAULT 'pending', + approved_at TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_token ON daemon_pairing_session(token); +CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_status ON daemon_pairing_session(status, expires_at); diff --git a/server/migrations/029_drop_daemon_pairing.up.sql b/server/migrations/029_drop_daemon_pairing.up.sql new file mode 100644 index 00000000..25e28eb7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_pairing_session; diff --git a/server/migrations/030_agent_default_private.down.sql b/server/migrations/030_agent_default_private.down.sql new file mode 100644 index 00000000..a7ea0a37 --- /dev/null +++ b/server/migrations/030_agent_default_private.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace'; diff --git a/server/migrations/030_agent_default_private.up.sql b/server/migrations/030_agent_default_private.up.sql new file mode 100644 index 00000000..7b85faef --- /dev/null +++ b/server/migrations/030_agent_default_private.up.sql @@ -0,0 +1 @@ +ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private'; diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index f345e656..a951d44e 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -458,6 +458,25 @@ func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUI return has_pending, err } +const hasPendingTaskForIssueAndAgent = `-- name: HasPendingTaskForIssueAndAgent :one +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched') +` + +type HasPendingTaskForIssueAndAgentParams struct { + IssueID pgtype.UUID `json:"issue_id"` + AgentID pgtype.UUID `json:"agent_id"` +} + +// Returns true if a specific agent already has a queued or dispatched task +// for the given issue. Used by @mention trigger dedup. +func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPendingTaskForIssueAndAgentParams) (bool, error) { + row := q.db.QueryRow(ctx, hasPendingTaskForIssueAndAgent, arg.IssueID, arg.AgentID) + var has_pending bool + err := row.Scan(&has_pending) + return has_pending, err +} + const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id FROM agent_task_queue WHERE issue_id = $1 AND status IN ('dispatched', 'running') diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go new file mode 100644 index 00000000..858365ad --- /dev/null +++ b/server/pkg/db/generated/attachment.sql.go @@ -0,0 +1,226 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: attachment.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAttachment = `-- name: CreateAttachment :one +INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7) +RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at +` + +type CreateAttachmentParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` +} + +func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, createAttachment, + arg.WorkspaceID, + arg.UploaderType, + arg.UploaderID, + arg.Filename, + arg.Url, + arg.ContentType, + arg.SizeBytes, + arg.IssueID, + arg.CommentID, + ) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const deleteAttachment = `-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2 +` + +type DeleteAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error { + _, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID) + return err +} + +const getAttachment = `-- name: GetAttachment :one +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE id = $1 AND workspace_id = $2 +` + +type GetAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const listAttachmentsByComment = `-- name: ListAttachmentsByComment :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE comment_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +` + +type ListAttachmentsByCommentParams struct { + CommentID pgtype.UUID `json:"comment_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC +` + +func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC +` + +type ListAttachmentsByIssueParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 78a736c5..926a3471 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -79,6 +79,20 @@ type AgentTaskQueue struct { TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` } +type Attachment struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Comment struct { ID pgtype.UUID `json:"id"` IssueID pgtype.UUID `json:"issue_id"` diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 83dd1a1b..2b581204 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -124,6 +124,12 @@ WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running'); SELECT count(*) > 0 AS has_pending FROM agent_task_queue WHERE issue_id = $1 AND status IN ('queued', 'dispatched'); +-- name: HasPendingTaskForIssueAndAgent :one +-- Returns true if a specific agent already has a queued or dispatched task +-- for the given issue. Used by @mention trigger dedup. +SELECT count(*) > 0 AS has_pending FROM agent_task_queue +WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched'); + -- name: ListPendingTasksByRuntime :many SELECT * FROM agent_task_queue WHERE runtime_id = $1 AND status IN ('queued', 'dispatched') diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql new file mode 100644 index 00000000..6003ab88 --- /dev/null +++ b/server/pkg/db/queries/attachment.sql @@ -0,0 +1,26 @@ +-- name: CreateAttachment :one +INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: ListAttachmentsByIssue :many +SELECT * FROM attachment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: ListAttachmentsByComment :many +SELECT * FROM attachment +WHERE comment_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: GetAttachment :one +SELECT * FROM attachment +WHERE id = $1 AND workspace_id = $2; + +-- name: ListAttachmentsByCommentIDs :many +SELECT * FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC; + +-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;