diff --git a/.env.example b/.env.example index 1c0c93ab..bfa38ae7 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,21 @@ 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 NEXT_PUBLIC_API_URL=http://localhost:8080 NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws + +# Remote API (optional) — set to proxy local frontend to a remote backend +# Leave empty to use local backend (localhost:8080) +# REMOTE_API_URL=https://multica-api.copilothub.ai 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/README.md b/README.md index e63c5ea3..db3e2660 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,46 @@ # Multica -AI-native task management platform — like Linear, but with AI agents as first-class citizens. +AI-native project management — like Linear, but with AI agents as first-class team members. -For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md). +Multica lets you manage tasks and collaborate with AI agents the same way you work with human teammates. Agents can be assigned issues, post comments, update statuses, and execute work autonomously on your local machine. -## Prerequisites +## Features -- [Node.js](https://nodejs.org/) (v20+) -- [pnpm](https://pnpm.io/) (v10.28+) -- [Go](https://go.dev/) (v1.26+) -- [Docker](https://www.docker.com/) +- **AI agents as teammates** — assign issues to agents, mention them in comments, and let them do the work +- **Local agent runtime** — agents run on your machine using Claude Code or Codex, with full access to your codebase +- **Real-time collaboration** — WebSocket-powered live updates across the board +- **Multi-workspace** — organize work across teams with workspace-level isolation +- **Familiar UX** — if you've used Linear, you'll feel right at home -## Quick Start +## Getting Started + +### Use Multica Cloud + +The fastest way to get started: [app.multica.ai](https://app.multica.ai) + +### Self-Host + +Run Multica on your own infrastructure. See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions. + +Quick start with Docker: ```bash -# 1. Install dependencies -pnpm install - -# 2. Copy environment variables for the shared main environment +git clone https://github.com/multica-ai/multica.git +cd multica cp .env.example .env +# Edit .env — at minimum, change JWT_SECRET -# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations -make setup +# Start PostgreSQL +docker compose up -d -# 4. Start backend + frontend +# Build and run the backend +cd server && go run ./cmd/migrate up && cd .. make start ``` -Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000). +## CLI -Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets: - -```bash -make worktree-env -make setup-worktree -make start-worktree -``` - -Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level: - -- `.env` typically uses `POSTGRES_DB=multica` -- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_my_feature_702` -- backend/frontend ports still stay unique per worktree - -That keeps one Docker container and one volume, while still isolating schema and data per worktree. - -## Project Structure - -``` -├── server/ # Go backend (Chi + sqlc + gorilla/websocket) -│ ├── cmd/ # server, daemon, migrate -│ ├── internal/ # Core business logic -│ ├── migrations/ # SQL migrations -│ └── sqlc.yaml # sqlc config -├── apps/ -│ └── web/ # Next.js 16 frontend -├── packages/ # Shared TypeScript packages -│ ├── ui/ # Component library (shadcn/ui + Radix) -│ ├── types/ # Shared type definitions -│ ├── sdk/ # API client SDK -│ ├── store/ # State management -│ ├── hooks/ # Shared React hooks -│ └── utils/ # Utility functions -├── Makefile # Backend commands -├── docker-compose.yml # PostgreSQL + pgvector -└── .env.example # Environment variable template -``` - -## Commands - -### Frontend - -| Command | Description | -|---------|-------------| -| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) | -| `pnpm build` | Build all TypeScript packages | -| `pnpm typecheck` | Run TypeScript type checking | -| `pnpm test` | Run TypeScript tests | - -### Backend - -| Command | Description | -|---------|-------------| -| `make dev` | Run Go server (uses `PORT`, default `8080`) | -| `make daemon` | Run local agent daemon | -| `make multica ARGS="version"` | Run the local `multica` CLI without installing it | -| `make test` | Run Go tests | -| `make build` | Build server & daemon binaries | -| `make sqlc` | Regenerate sqlc code from SQL | - -### Database - -| Command | Description | -|---------|-------------| -| `make db-up` | Start the shared PostgreSQL container | -| `make db-down` | Stop the shared PostgreSQL container | -| `make migrate-up` | Ensure the current DB exists, then run migrations | -| `make migrate-down` | Rollback database migrations for the current DB | -| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree | -| `make setup-main` / `make start-main` | Force use of the shared main `.env` | -| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` | - -## CLI (`multica`) - -The CLI manages authentication, workspace configuration, and the local agent daemon. +The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon. ### Install @@ -116,96 +53,74 @@ Or build from source: ```bash make build -cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica +cp server/bin/multica /usr/local/bin/multica ``` -For local development, you can also run the CLI directly from the repo: - -```bash -make multica ARGS="version" -make multica ARGS="auth status" -``` - -For browser-based auth from source, make sure the local frontend is running at `FRONTEND_ORIGIN` first, for example with `make start`, `make start-main`, or `make start-worktree`. - -### Authentication - -```bash -multica login # Authenticate and auto-watch your workspaces -multica auth login # Legacy auth-only flow -multica auth login --token # Legacy token-only auth flow -multica auth status # Show current auth status -multica auth logout # Remove stored token -``` - -Credentials are saved to `~/.multica/config.json`. - -### Workspaces - -```bash -multica workspace list # List all workspaces you belong to -multica workspace get # Show the current workspace details/context -``` - -### Daemon Watch List - -The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched: - -```bash -multica workspace watch # Add a workspace to the watch list -multica workspace unwatch # Remove a workspace from the watch list -multica workspace list # Show all workspaces (watched ones marked with *) -``` - -The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload). - -### Local Agent Daemon - -The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex). +### Connect Your Agent Runtime ```bash # 1. Authenticate multica login -# 2. Add workspaces to watch +# 2. Watch your workspace multica workspace watch -# 3. Start the daemon +# 3. Start the local agent daemon multica daemon start ``` -The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server. +The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back. ### Other Commands ```bash -multica agent list # List agents in the current workspace -multica daemon status # Show local daemon status -multica config # Show CLI configuration -multica config show # Compatibility alias for config display -multica version # Show CLI version +multica workspace list # List workspaces (watched ones marked with *) +multica agent list # List agents in the current workspace +multica daemon status # Show daemon status +multica version # Show CLI version ``` -## Environment Variables +## Architecture -See [`.env.example`](.env.example) for all available variables: +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Next.js │────>│ Go Backend │────>│ PostgreSQL │ +│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │ +└──────────────┘ └──────┬───────┘ └──────────────────┘ + │ + ┌──────┴───────┐ + │ Agent Daemon │ (runs on your machine) + │ Claude / Codex│ + └──────────────┘ +``` -- `DATABASE_URL` — PostgreSQL connection string -- `POSTGRES_DB` — Database name for the current checkout or worktree -- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`) -- `PORT` — Backend server port (default: 8080) -- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin -- `JWT_SECRET` — JWT signing secret -- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`) -- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration -- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override -- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override -- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`) -- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL -- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL +- **Frontend**: Next.js 16 (App Router) +- **Backend**: Go (Chi router, sqlc, gorilla/websocket) +- **Database**: PostgreSQL 17 with pgvector +- **Agent Runtime**: Local daemon executing Claude Code or Codex -## Local Development Notes +## Development -- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing. -- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container. -- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout. +For contributors working on the Multica codebase, see the [Local Development Guide](LOCAL_DEVELOPMENT.md). + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v20+) +- [pnpm](https://pnpm.io/) (v10.28+) +- [Go](https://go.dev/) (v1.26+) +- [Docker](https://www.docker.com/) + +### Quick Start + +```bash +pnpm install +cp .env.example .env +make setup +make start +``` + +See [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) for the full development workflow, worktree support, testing, and troubleshooting. + +## License + +See [LICENSE](LICENSE) for details. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md new file mode 100644 index 00000000..8e671b53 --- /dev/null +++ b/SELF_HOSTING.md @@ -0,0 +1,278 @@ +# Self-Hosting Guide + +This guide walks you through deploying Multica on your own infrastructure. + +## Architecture Overview + +Multica has three components: + +| Component | Description | Technology | +|-----------|-------------|------------| +| **Backend** | REST API + WebSocket server | Go (single binary) | +| **Frontend** | Web application | Next.js 16 | +| **Database** | Primary data store | PostgreSQL 17 with pgvector | + +Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine. + +## Prerequisites + +- Docker and Docker Compose (recommended), or: + - Go 1.26+ (to build from source) + - Node.js 20+ and pnpm 10.28+ (to build the frontend) + - PostgreSQL 17 with the pgvector extension + +## Quick Start (Docker Compose) + +```bash +git clone https://github.com/multica-ai/multica.git +cd multica +cp .env.example .env +``` + +Edit `.env` with your production values (see [Configuration](#configuration) below), then: + +```bash +# Start PostgreSQL +docker compose up -d + +# Build the backend +make build + +# Run database migrations +DATABASE_URL="your-database-url" ./server/bin/migrate up + +# Start the backend server +DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server +``` + +For the frontend: + +```bash +pnpm install +pnpm build + +# Start the frontend (production mode) +cd apps/web +REMOTE_API_URL=http://localhost:8080 pnpm start +``` + +## Configuration + +All configuration is done via environment variables. Copy `.env.example` as a starting point. + +### Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | +| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` | +| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` | + +### Email (Required for Authentication) + +Multica uses email-based magic link authentication via [Resend](https://resend.com). + +| Variable | Description | +|----------|-------------| +| `RESEND_API_KEY` | Your Resend API key | +| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) | + +### Google OAuth (Optional) + +| Variable | Description | +|----------|-------------| +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | +| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) | + +### File Storage (Optional) + +For file uploads and attachments, configure S3 and CloudFront: + +| Variable | Description | +|----------|-------------| +| `S3_BUCKET` | S3 bucket name | +| `S3_REGION` | AWS region (default: `us-west-2`) | +| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain | +| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs | +| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) | +| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies | + +### Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Backend server port | +| `FRONTEND_PORT` | `3000` | Frontend port | +| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins | +| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | + +### CLI / Daemon + +These are configured on each user's machine, not on the server: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection | +| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow | +| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks | +| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency | + +## Database Setup + +Multica requires PostgreSQL 17 with the pgvector extension. + +### Using the Included Docker Compose + +```bash +docker compose up -d postgres +``` + +This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`). + +### Using Your Own PostgreSQL + +Ensure the pgvector extension is available: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +``` + +### Running Migrations + +Migrations must be run before starting the server: + +```bash +# Using the built binary +./server/bin/migrate up + +# Or from source +cd server && go run ./cmd/migrate up +``` + +## Reverse Proxy + +In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing. + +### Caddy (Recommended) + +``` +app.example.com { + reverse_proxy localhost:3000 +} + +api.example.com { + reverse_proxy localhost:8080 +} +``` + +### Nginx + +```nginx +# Frontend +server { + listen 443 ssl; + server_name app.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Backend API +server { + listen 443 ssl; + server_name api.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket support + location /ws { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } +} +``` + +When using separate domains for frontend and backend, set these environment variables accordingly: + +```bash +# Backend +FRONTEND_ORIGIN=https://app.example.com +CORS_ALLOWED_ORIGINS=https://app.example.com + +# Frontend +REMOTE_API_URL=https://api.example.com +NEXT_PUBLIC_API_URL=https://api.example.com +NEXT_PUBLIC_WS_URL=wss://api.example.com/ws +``` + +## Health Check + +The backend exposes a health check endpoint: + +``` +GET /health +→ {"status":"ok"} +``` + +Use this for load balancer health checks or monitoring. + +## Setting Up the Agent Daemon + +Each team member who wants to run AI agents locally needs to: + +1. **Install the CLI** + + ```bash + brew tap multica-ai/tap + brew install multica-cli + ``` + +2. **Install an AI agent CLI** — at least one of: + - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH) + - [Codex](https://github.com/openai/codex) (`codex` on PATH) + +3. **Authenticate and start** + + ```bash + # Point CLI to your server + export MULTICA_APP_URL=https://app.example.com + export MULTICA_SERVER_URL=wss://api.example.com/ws + + # Login (opens browser) + multica login + + # Start the daemon + multica daemon start + ``` + +The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back. + +## Upgrading + +1. Pull the latest code or image +2. Run migrations: `./server/bin/migrate up` +3. Restart the backend and frontend + +Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect. diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 97f1416c..a6e8c092 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 */} @@ -1420,6 +1561,7 @@ export default function AgentsPage() { {/* Right column — agent detail */} {selected ? ( void; + onArchive: () => void; }) { return ( + +
+ 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/code-block-view.tsx b/apps/web/components/common/code-block-view.tsx new file mode 100644 index 00000000..db274b84 --- /dev/null +++ b/apps/web/components/common/code-block-view.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; +import type { NodeViewProps } from "@tiptap/react"; +import { Copy, Check } from "lucide-react"; + +function CodeBlockView({ node }: NodeViewProps) { + const [copied, setCopied] = useState(false); + const language = node.attrs.language || ""; + + const handleCopy = async () => { + const text = node.textContent; + if (!text) return; + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + +
+ {language && ( + + {language} + + )} + +
+
+        {/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
+        
+      
+
+ ); +} + +export { CodeBlockView }; diff --git a/apps/web/components/common/file-upload-button.tsx b/apps/web/components/common/file-upload-button.tsx new file mode 100644 index 00000000..1c1ddc37 --- /dev/null +++ b/apps/web/components/common/file-upload-button.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useRef } from "react"; +import { Paperclip } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { UploadResult } from "@/shared/hooks/use-file-upload"; + +interface FileUploadButtonProps { + onUpload: (file: File) => Promise; + onInsert?: (result: UploadResult, isImage: boolean) => void; + disabled?: boolean; + className?: string; + size?: "sm" | "default"; +} + +function FileUploadButton({ + onUpload, + onInsert, + disabled, + className, + size = "default", +}: FileUploadButtonProps) { + const inputRef = useRef(null); + + const handleChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + const result = await onUpload(file); + if (result && onInsert) { + onInsert(result, file.type.startsWith("image/")); + } + }; + + const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"; + const btnSize = size === "sm" ? "h-6 w-6" : "h-7 w-7"; + + return ( + <> + + + + ); +} + +export { FileUploadButton, type FileUploadButtonProps }; 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/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index 0cd09b10..b00b4c1d 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -6,10 +6,11 @@ import { useImperativeHandle, useState, } from "react"; -import { Bot } from "lucide-react"; +import { Bot, Hash } from "lucide-react"; import { ReactRenderer } from "@tiptap/react"; import { computePosition, offset, flip, shift } from "@floating-ui/dom"; import { useWorkspaceStore } from "@/features/workspace"; +import { useIssueStore } from "@/features/issues"; import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; // --------------------------------------------------------------------------- @@ -19,7 +20,9 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; export interface MentionItem { id: string; label: string; - type: "member" | "agent"; + type: "member" | "agent" | "issue"; + /** Secondary text shown below the label (e.g. issue title) */ + description?: string; } interface MentionListProps { @@ -88,6 +91,10 @@ const MentionList = forwardRef( + ) : item.type === "issue" ? ( + + + ) : ( {item.label @@ -98,7 +105,12 @@ const MentionList = forwardRef( .slice(0, 2)} )} - {item.label} +
+ {item.label} + {item.description && ( + {item.description} + )} +
))}
@@ -117,6 +129,7 @@ export function createMentionSuggestion(): Omit< return { items: ({ query }) => { const { members, agents } = useWorkspaceStore.getState(); + const { issues } = useIssueStore.getState(); const q = query.toLowerCase(); const memberItems: MentionItem[] = members @@ -131,7 +144,20 @@ export function createMentionSuggestion(): Omit< .filter((a) => a.name.toLowerCase().includes(q)) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); - return [...memberItems, ...agentItems].slice(0, 10); + const issueItems: MentionItem[] = issues + .filter( + (i) => + i.identifier.toLowerCase().includes(q) || + i.title.toLowerCase().includes(q), + ) + .map((i) => ({ + id: i.id, + label: i.identifier, + type: "issue" as const, + description: i.title, + })); + + return [...memberItems, ...agentItems, ...issueItems].slice(0, 10); }, render: () => { diff --git a/apps/web/components/common/quick-emoji-picker.tsx b/apps/web/components/common/quick-emoji-picker.tsx new file mode 100644 index 00000000..af2e39f7 --- /dev/null +++ b/apps/web/components/common/quick-emoji-picker.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, lazy, Suspense } from "react"; +import { SmilePlus } from "lucide-react"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; + +const EmojiPicker = lazy(() => + import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), +); + +const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; + +interface QuickEmojiPickerProps { + onSelect: (emoji: string) => void; + align?: "start" | "end"; + className?: string; +} + +function QuickEmojiPicker({ onSelect, align = "start", className }: QuickEmojiPickerProps) { + const [open, setOpen] = useState(false); + const [showFull, setShowFull] = useState(false); + + const handleOpenChange = (v: boolean) => { + setOpen(v); + if (!v) setShowFull(false); + }; + + const handleSelect = (emoji: string) => { + onSelect(emoji); + setOpen(false); + setShowFull(false); + }; + + return ( + + + + + } + /> + + {showFull ? ( + Loading...}> + + + ) : ( +
+
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ +
+ )} +
+
+ ); +} + +export { QuickEmojiPicker }; diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx index fa98a5fc..0786d685 100644 --- a/apps/web/components/common/reaction-bar.tsx +++ b/apps/web/components/common/reaction-bar.tsx @@ -1,17 +1,9 @@ "use client"; -import { useState, lazy, Suspense } from "react"; -import { SmilePlus } from "lucide-react"; -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker"; import { useActorName } from "@/features/workspace"; -const EmojiPicker = lazy(() => - import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), -); - -const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; - interface ReactionItem { id: string; actor_type: string; @@ -48,22 +40,17 @@ export function ReactionBar({ currentUserId, onToggle, className, + hideAddButton, }: { reactions: ReactionItem[]; currentUserId?: string; onToggle: (emoji: string) => void; className?: string; + hideAddButton?: boolean; }) { - const [pickerOpen, setPickerOpen] = useState(false); - const [showFullPicker, setShowFullPicker] = useState(false); const grouped = groupReactions(reactions, currentUserId); const { getActorName } = useActorName(); - const handlePickerOpenChange = (open: boolean) => { - setPickerOpen(open); - if (!open) setShowFullPicker(false); - }; - return (
{grouped.map((g) => ( @@ -73,10 +60,10 @@ export function ReactionBar({ - } - /> - - {showFullPicker ? ( - Loading...
}> - { - onToggle(emoji); - setPickerOpen(false); - setShowFullPicker(false); - }} - /> - - ) : ( -
-
- {QUICK_EMOJIS.map((emoji) => ( - - ))} -
- -
- )} - - + {!hideAddButton && } ); } diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index 47c3c416..5883156d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -109,6 +109,63 @@ line-height: 1.6; } +/* Syntax highlighting — lowlight (hljs) */ +.rich-text-editor .hljs-keyword, +.rich-text-editor .hljs-selector-tag, +.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); } + +.rich-text-editor .hljs-string, +.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); } + +.rich-text-editor .hljs-comment, +.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; } + +.rich-text-editor .hljs-number, +.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); } + +.rich-text-editor .hljs-title, +.rich-text-editor .hljs-section, +.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); } + +.rich-text-editor .hljs-attr, +.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); } + +.rich-text-editor .hljs-variable, +.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); } + +.rich-text-editor .hljs-type, +.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); } + +.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); } + +.rich-text-editor .hljs-meta { color: var(--muted-foreground); } + +/* Dark mode overrides */ +.dark .rich-text-editor .hljs-keyword, +.dark .rich-text-editor .hljs-selector-tag, +.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); } + +.dark .rich-text-editor .hljs-string, +.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); } + +.dark .rich-text-editor .hljs-number, +.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); } + +.dark .rich-text-editor .hljs-title, +.dark .rich-text-editor .hljs-section, +.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); } + +.dark .rich-text-editor .hljs-attr, +.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); } + +.dark .rich-text-editor .hljs-variable, +.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); } + +.dark .rich-text-editor .hljs-type, +.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); } + +.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); } + /* Blockquotes */ .rich-text-editor blockquote { border-left: 2px solid var(--border); @@ -126,7 +183,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 +195,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 */ @@ -154,3 +213,15 @@ text-decoration: line-through; color: var(--muted-foreground); } + +/* Uploading image placeholder (blob: URLs = in-flight uploads) */ +.rich-text-editor img[src^="blob:"] { + opacity: 0.5; + border-radius: var(--radius); + animation: rte-pulse 1.5s ease-in-out infinite; +} + +@keyframes rte-pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.3; } +} diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..a8687af5 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -6,18 +6,27 @@ import { useImperativeHandle, useRef, } from "react"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { common, createLowlight } from "lowlight"; 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 { Slice } from "@tiptap/pm/model"; import { cn } from "@/lib/utils"; +import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { createMentionSuggestion } from "./mention-suggestion"; +import { CodeBlockView } from "./code-block-view"; import "./rich-text-editor.css"; +const lowlight = createLowlight(common); + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -30,56 +39,23 @@ interface RichTextEditorProps { className?: string; debounceMs?: number; onSubmit?: () => void; + onBlur?: () => 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({ @@ -88,13 +64,16 @@ const MentionExtension = Mention.configure({ }).extend({ renderHTML({ node, HTMLAttributes }) { return [ - "a", - { - ...HTMLAttributes, - href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`, - "data-mention-type": node.attrs.type ?? "member", - "data-mention-id": node.attrs.id, - }, + "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}`, ]; }, @@ -103,21 +82,39 @@ const MentionExtension = Mention.configure({ ...this.parent?.(), type: { default: "member", - parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", + parseHTML: (el: HTMLElement) => + el.getAttribute("data-mention-type") ?? "member", + renderHTML: () => ({}), }, }; }, - addStorage() { - return { - markdown: { - serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { - state.write( - `[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`, - ); - }, - parse: {}, - }, - }; + // @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 + 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})`; }, }); @@ -139,6 +136,163 @@ function createSubmitExtension(onSubmit: () => void) { }); } +// --------------------------------------------------------------------------- +// Markdown paste extension — parse pasted markdown text as rich text +// --------------------------------------------------------------------------- + +function createMarkdownPasteExtension() { + return Extension.create({ + name: "markdownPaste", + addProseMirrorPlugins() { + const { editor } = this; + return [ + new Plugin({ + key: new PluginKey("markdownPaste"), + props: { + clipboardTextParser(text, _context, plainText) { + if (!plainText && editor.markdown) { + const json = editor.markdown.parse(text); + const node = editor.schema.nodeFromJSON(json); + return Slice.maxOpen(node.content); + } + // Plain text fallback + const p = editor.schema.nodes.paragraph!; + const doc = editor.schema.nodes.doc!; + const paragraph = p.create(null, text ? editor.schema.text(text) : undefined); + return new Slice(doc.create(null, paragraph).content, 0, 0); + }, + }, + }), + ]; + }, + }); +} + +// --------------------------------------------------------------------------- +// File upload extension (paste + drop) with blob URL instant preview +// --------------------------------------------------------------------------- + +function removeImageBySrc(editor: ReturnType, src: string) { + if (!editor) return; + const { tr } = editor.state; + let deleted = false; + editor.state.doc.descendants((node, pos) => { + if (deleted) return false; + if (node.type.name === "image" && node.attrs.src === src) { + tr.delete(pos, pos + node.nodeSize); + deleted = true; + return false; + } + }); + if (deleted) editor.view.dispatch(tr); +} + +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; + const isImage = file.type.startsWith("image/"); + + if (isImage) { + // Instant preview via blob URL, then replace with real URL after upload + const blobUrl = URL.createObjectURL(file); + if (pos !== undefined) { + editor + .chain() + .focus() + .insertContentAt(pos, { + type: "image", + attrs: { src: blobUrl, alt: file.name }, + }) + .run(); + } else { + editor + .chain() + .focus() + .setImage({ src: blobUrl, alt: file.name }) + .run(); + } + + try { + const result = await handler(file); + if (result) { + const { tr } = editor.state; + editor.state.doc.descendants((node, nodePos) => { + if ( + node.type.name === "image" && + node.attrs.src === blobUrl + ) { + tr.setNodeMarkup(nodePos, undefined, { + ...node.attrs, + src: result.link, + alt: result.filename, + }); + } + }); + editor.view.dispatch(tr); + } else { + removeImageBySrc(editor, blobUrl); + } + } catch { + removeImageBySrc(editor, blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + } else { + // Non-image: upload first, then insert link + try { + const result = await handler(file); + if (!result) continue; + 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 // --------------------------------------------------------------------------- @@ -153,47 +307,104 @@ const RichTextEditor = forwardRef( className, debounceMs = 300, onSubmit, + onBlur, + onUploadFile, }, ref, ) { const debounceRef = useRef>(undefined); const onUpdateRef = useRef(onUpdate); const onSubmitRef = useRef(onSubmit); + const onBlurRef = useRef(onBlur); + const onUploadFileRef = useRef(onUploadFile); - // Helper to get markdown from tiptap-markdown storage + // Helper to get markdown from @tiptap/markdown extension. + // Post-processes mention shortcodes [@ id="..." label="..."] → markdown + // links, using the Tiptap JSON doc for type info, in case the + // renderMarkdown override doesn't take effect. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getEditorMarkdown = (ed: any): string => - ed?.storage?.markdown?.getMarkdown?.() ?? ""; + const getEditorMarkdown = (ed: any): string => { + const md: string = ed?.getMarkdown?.() ?? ""; + if (!md || !md.includes("[@ ")) return md; + + // Build type map from editor JSON (which always has the type attr) + const json = ed?.getJSON?.(); + const typeMap = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function walk(node: any) { + if (node?.type === "mention" && node.attrs?.id) { + typeMap.set(node.attrs.id, node.attrs.type || "member"); + } + if (node?.content) node.content.forEach(walk); + } + if (json) walk(json); + + return md.replace( + /\[@\s+([^\]]*)\]/g, + (match: string, attrString: string) => { + const attrs: Record = {}; + const re = /(\w+)="([^"]*)"/g; + let m; + while ((m = re.exec(attrString)) !== null) { + if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]; + } + const { id, label } = attrs; + if (!id || !label) return match; + const type = typeMap.get(id) || "member"; + const display = type === "issue" ? label : `@${label}`; + return `[${display}](mention://${type}/${id})`; + }, + ); + }; // Keep refs in sync without recreating editor onUpdateRef.current = onUpdate; onSubmitRef.current = onSubmit; + onBlurRef.current = onBlur; + 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] }, link: false, + codeBlock: false, }), + CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView); + }, + }).configure({ lowlight }), Placeholder.configure({ placeholder: placeholderText, }), LinkExtension, Typography, MentionExtension, + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { style: "max-width: 100%; height: auto;" }, + }), Markdown, + createMarkdownPasteExtension(), 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); }, + onBlur: () => { + onBlurRef.current?.(); + }, editorProps: { handleDOMEvents: { click(_view, event) { @@ -223,13 +434,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 511084da..351ac8c6 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -1,10 +1,11 @@ import * as React from 'react' -import ReactMarkdown, { type Components } from 'react-markdown' +import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessLinks } from './linkify' +import { IssueMentionCard } from '@/features/issues/components/issue-mention-card' /** * Render modes for markdown content: @@ -43,6 +44,37 @@ export interface MarkdownProps { onFileClick?: (path: string) => void } +/** + * Custom URL transform that allows mention:// protocol (used for @mentions) + * while keeping the default security for all other URLs. + */ +function urlTransform(url: string): string { + if (url.startsWith('mention://')) return url + return defaultUrlTransform(url) +} + +/** + * Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown + * link format [@LABEL](mention://member/UUID) so they render as styled mentions. + */ +function preprocessMentionShortcodes(text: string): string { + if (!text.includes('[@ ')) return text + return text.replace( + /\[@\s+([^\]]*)\]/g, + (match, attrString: string) => { + const attrs: Record = {} + const re = /(\w+)="([^"]*)"/g + let m + while ((m = re.exec(attrString)) !== null) { + if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2] + } + const { id, label } = attrs + if (!id || !label) return match + return `[@${label}](mention://member/${id})` + } + ) +} + // File path detection regex - matches paths starting with /, ~/, or ./ const FILE_PATH_REGEX = /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i @@ -56,15 +88,26 @@ 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 or mention://agent/id + // Mention links: mention://member/id, mention://agent/id, mention://issue/id if (href?.startsWith('mention://')) { + const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/) + if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) { + const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined + return + } return ( - + {children} ) @@ -276,14 +319,18 @@ export function Markdown({ [mode, onUrlClick, onFileClick] ) - // Preprocess to convert raw URLs and file paths to markdown links - const processedContent = React.useMemo(() => preprocessLinks(children), [children]) + // Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links + const processedContent = React.useMemo( + () => preprocessLinks(preprocessMentionShortcodes(children)), + [children] + ) return (
{processedContent} diff --git a/apps/web/features/auth/initializer.tsx b/apps/web/features/auth/initializer.tsx index ae7820c3..ffb0b87a 100644 --- a/apps/web/features/auth/initializer.tsx +++ b/apps/web/features/auth/initializer.tsx @@ -10,26 +10,37 @@ const logger = createLogger("auth"); /** * Initializes auth + workspace state from localStorage on mount. - * Must wrap the app to ensure stores are hydrated before children render. + * Fires getMe() and listWorkspaces() in parallel when a cached token exists. */ export function AuthInitializer({ children }: { children: ReactNode }) { - const initialize = useAuthStore((s) => s.initialize); - const user = useAuthStore((s) => s.user); - const isLoading = useAuthStore((s) => s.isLoading); - const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); - useEffect(() => { - initialize(); - }, [initialize]); + const token = localStorage.getItem("multica_token"); + if (!token) { + useAuthStore.setState({ isLoading: false }); + return; + } - useEffect(() => { - if (isLoading || !user) return; + api.setToken(token); const wsId = localStorage.getItem("multica_workspace_id"); - api.listWorkspaces().then((wsList) => { - hydrateWorkspace(wsList, wsId); - }).catch((err) => logger.error("workspace hydration failed", err)); - }, [user, isLoading, hydrateWorkspace]); + // Fire getMe and listWorkspaces in parallel + const mePromise = api.getMe(); + const wsPromise = api.listWorkspaces(); + + Promise.all([mePromise, wsPromise]) + .then(([user, wsList]) => { + useAuthStore.setState({ user, isLoading: false }); + useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId); + }) + .catch((err) => { + logger.error("auth init failed", err); + api.setToken(null); + api.setWorkspaceId(null); + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + useAuthStore.setState({ user: null, isLoading: false }); + }); + }, []); return <>{children}; } diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 73271732..6917fc0f 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -8,6 +8,7 @@ import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskC import type { AgentTask } from "@/shared/types/agent"; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { useActorName } from "@/features/workspace"; import { redactSecrets } from "../utils/redact"; // ─── Shared types & helpers ───────────────────────────────────────────────── @@ -96,12 +97,11 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { interface AgentLiveCardProps { issueId: string; - assigneeType: string | null; - assigneeId: string | null; agentName?: string; } -export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: AgentLiveCardProps) { +export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { + const { getActorName } = useActorName(); const [activeTask, setActiveTask] = useState(null); const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); @@ -112,11 +112,6 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: // Check for active task on mount useEffect(() => { - if (assigneeType !== "agent" || !assigneeId) { - setActiveTask(null); - return; - } - let cancelled = false; api.getActiveTaskForIssue(issueId).then(({ task }) => { if (!cancelled) { @@ -134,7 +129,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: }).catch(() => {}); return () => { cancelled = true; }; - }, [issueId, assigneeType, assigneeId]); + }, [issueId]); // Handle real-time task messages useWSEvent( @@ -258,7 +253,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
- {agentName ?? "Agent"} is working + {(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working
{elapsed} {toolCount > 0 && ( @@ -316,17 +311,15 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: interface TaskRunHistoryProps { issueId: string; - assigneeType: string | null; } -export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) { +export function TaskRunHistory({ issueId }: TaskRunHistoryProps) { const [tasks, setTasks] = useState([]); const [open, setOpen] = useState(false); useEffect(() => { - if (assigneeType !== "agent") return; api.listTasksByIssue(issueId).then(setTasks).catch(() => {}); - }, [issueId, assigneeType]); + }, [issueId]); // Refresh when a task completes useWSEvent( diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index edf56959..4ccd67b0 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, Lock, UserMinus } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -19,12 +19,14 @@ 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 { useWorkspaceStore, useActorName } from "@/features/workspace"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; import { useIssueStore } from "@/features/issues/store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { api } from "@/shared/api"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; @@ -206,6 +208,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,9 +227,11 @@ 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) => @@ -283,9 +294,7 @@ function BatchAssigneePicker({ }} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} ))} @@ -297,22 +306,28 @@ 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 b72baec2..ebea665a 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 } 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"; @@ -13,11 +13,17 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; 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 { QuickEmojiPicker } from "@/components/common/quick-emoji-picker"; +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 { Markdown } from "@/components/markdown/Markdown"; +import { FileUploadButton } from "@/components/common/file-upload-button"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { ReplyInput } from "./reply-input"; import type { TimelineEntry } from "@/shared/types"; @@ -26,10 +32,11 @@ import type { TimelineEntry } from "@/shared/types"; // --------------------------------------------------------------------------- interface CommentCardProps { + issueId: string; entry: TimelineEntry; allReplies: Map; currentUserId?: string; - onReply: (parentId: string, content: string) => Promise; + onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; onToggleReaction: (commentId: string, emoji: string) => void; @@ -40,12 +47,14 @@ interface CommentCardProps { // --------------------------------------------------------------------------- function CommentRow({ + issueId, entry, currentUserId, onEdit, onDelete, onToggleReaction, }: { + issueId: string; entry: TimelineEntry; currentUserId?: string; onEdit: (commentId: string, content: string) => Promise; @@ -54,28 +63,36 @@ function CommentRow({ }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); - const [editContent, setEditContent] = useState(""); + const editEditorRef = useRef(null); + const cancelledRef = useRef(false); + const { uploadWithToast } = useFileUpload(); const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); const startEdit = () => { - setEditContent(entry.content ?? ""); + cancelledRef.current = false; setEditing(true); }; const cancelEdit = () => { + cancelledRef.current = true; setEditing(false); - setEditContent(""); }; const saveEdit = async () => { - const trimmed = editContent.trim(); - if (!trimmed) return; + if (cancelledRef.current) return; + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); + if (!trimmed || trimmed === (entry.content ?? "").trim()) { + setEditing(false); + return; + } try { await onEdit(entry.id, trimmed); setEditing(false); - setEditContent(""); } catch { toast.error("Failed to update comment"); } @@ -104,10 +121,15 @@ function CommentRow({ {!isTemp && ( +
+ onToggleReaction(entry.id, emoji)} + align="end" + /> + } @@ -136,27 +158,36 @@ function CommentRow({ )} +
)} {editing ? ( -
{ e.preventDefault(); saveEdit(); }} - className="mt-2 pl-8" +
{ 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(); }} - /> -
- - +
+
- +
+ uploadWithToast(file, { issueId })} + onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
+ + +
+
+
) : ( <>
@@ -167,6 +198,7 @@ function CommentRow({ reactions={reactions} currentUserId={currentUserId} onToggle={(emoji) => onToggleReaction(entry.id, emoji)} + hideAddButton className="mt-1.5 pl-8" /> )} @@ -181,6 +213,7 @@ function CommentRow({ // --------------------------------------------------------------------------- function CommentCard({ + issueId, entry, allReplies, currentUserId, @@ -189,6 +222,44 @@ function CommentCard({ onDelete, onToggleReaction, }: CommentCardProps) { + const { getActorName } = useActorName(); + const { uploadWithToast } = useFileUpload(); + const [open, setOpen] = useState(true); + const [editing, setEditing] = useState(false); + const editEditorRef = useRef(null); + const cancelledRef = useRef(false); + + const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; + const isTemp = entry.id.startsWith("temp-"); + + const startEdit = () => { + cancelledRef.current = false; + setEditing(true); + }; + + const cancelEdit = () => { + cancelledRef.current = true; + setEditing(false); + }; + + const saveEdit = async () => { + if (cancelledRef.current) return; + const trimmed = editEditorRef.current + ?.getMarkdown() + ?.replace(/(\n\s*)+$/, "") + .trim(); + if (!trimmed || trimmed === (entry.content ?? "").trim()) { + setEditing(false); + return; + } + try { + await onEdit(entry.id, trimmed); + setEditing(false); + } catch { + toast.error("Failed to update comment"); + } + }; + // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; const collectReplies = (parentId: string) => { @@ -200,42 +271,164 @@ function CommentCard({ }; collectReplies(entry.id); + const replyCount = allNestedReplies.length; + const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); + const reactions = entry.reactions ?? []; + return ( - - {/* Parent comment */} -
- -
+ + + {/* Header — always visible, acts as toggle */} +
+
+ + + + + + {getActorName(entry.actor_type, entry.actor_id)} + + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + - {/* Replies — flat, separated by border */} - {allNestedReplies.map((reply) => ( -
- + {!open && contentPreview && ( + + {contentPreview} + + )} + {!open && replyCount > 0 && ( + + {replyCount} {replyCount === 1 ? "reply" : "replies"} + + )} + + {open && !isTemp && ( +
+ onToggleReaction(entry.id, emoji)} + align="end" + /> + + + + + } + /> + + { + navigator.clipboard.writeText(entry.content ?? ""); + toast.success("Copied"); + }}> + + Copy + + {isOwn && ( + <> + + + + Edit + + + onDelete(entry.id)} variant="destructive"> + + Delete + + + )} + + +
+ )} +
- ))} - {/* Reply input — always visible at bottom */} -
- onReply(entry.id, content)} - /> -
+ {/* Collapsible body */} + + {/* Parent comment body */} +
+ {editing ? ( +
{ if (e.key === "Escape") cancelEdit(); }} + > +
+ +
+
+ uploadWithToast(file, { issueId })} + onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
+ + +
+
+
+ ) : ( + <> +
+ {entry.content ?? ""} +
+ {!isTemp && ( + onToggleReaction(entry.id, emoji)} + className="mt-1.5 pl-10" + /> + )} + + )} +
+ + {/* Replies */} + {allNestedReplies.map((reply) => ( +
+ +
+ ))} + + {/* Reply input */} +
+ onReply(entry.id, content, attachmentIds)} + /> +
+
+ ); } diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 6e2b8052..08bf934d 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -1,26 +1,39 @@ "use client"; import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; +import { FileUploadButton } from "@/components/common/file-upload-button"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; interface CommentInputProps { - onSubmit: (content: string) => Promise; + issueId: string; + onSubmit: (content: string, attachmentIds?: string[]) => Promise; } -function CommentInput({ onSubmit }: CommentInputProps) { +function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); + const attachmentIdsRef = useRef([]); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); + const { uploadWithToast, uploading } = useFileUpload(); + + const handleUpload = async (file: File) => { + const result = await uploadWithToast(file, { issueId }); + if (result) attachmentIdsRef.current.push(result.id); + return result; + }; const handleSubmit = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || submitting) return; setSubmitting(true); try { - await onSubmit(content); + const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; + await onSubmit(content, ids); editorRef.current?.clearContent(); + attachmentIdsRef.current = []; setIsEmpty(true); } finally { setSubmitting(false); @@ -28,23 +41,36 @@ function CommentInput({ onSubmit }: CommentInputProps) { }; return ( -
-
+
+
setIsEmpty(!md.trim())} onSubmit={handleSubmit} + onUploadFile={handleUpload} debounceMs={100} />
-
+
+ + editorRef.current?.insertFile(result.filename, result.link, isImage) + } + disabled={uploading} + />
diff --git a/apps/web/features/issues/components/index.ts b/apps/web/features/issues/components/index.ts index c95fe21e..37608d08 100644 --- a/apps/web/features/issues/components/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page"; export { CommentCard } from "./comment-card"; export { CommentInput } from "./comment-input"; export { ReplyInput } from "./reply-input"; +export { IssueMentionCard } from "./issue-mention-card"; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 53e44bb9..c6be3dfe 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -5,7 +5,6 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - Bot, Calendar, Check, ChevronLeft, @@ -43,8 +42,9 @@ 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 { FileUploadButton } from "@/components/common/file-upload-button"; +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,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo [issue, id], ); + const descEditorRef = useRef(null); + const handleDescriptionUpload = useCallback( + (file: File) => uploadWithToast(file, { issueId: id }), + [uploadWithToast, id], + ); + const handleDelete = async () => { setDeleting(true); try { @@ -421,9 +420,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} {issue.assignee_type === "member" && issue.assignee_id === m.user_id && } @@ -433,9 +430,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} > -
- -
+ {a.name} {issue.assignee_type === "agent" && issue.assignee_id === a.id && } @@ -547,43 +542,40 @@ 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" /> - +
+ + descEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
@@ -675,15 +667,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Agent execution history */}
- +
{/* Timeline entries */} @@ -741,6 +731,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo return ( - +
@@ -904,9 +895,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo Members {members.map((m) => ( handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}> -
- {getActorInitials("member", m.user_id)} -
+ {m.name}
))} @@ -920,9 +909,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo Agents {agents.map((a) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}> -
- -
+ {a.name}
))} diff --git a/apps/web/features/issues/components/issue-mention-card.tsx b/apps/web/features/issues/components/issue-mention-card.tsx new file mode 100644 index 00000000..9862fffe --- /dev/null +++ b/apps/web/features/issues/components/issue-mention-card.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { useIssueStore } from "@/features/issues/store"; +import { StatusIcon } from "./status-icon"; + +interface IssueMentionCardProps { + issueId: string; + /** Fallback text when issue is not in store (e.g. "MUL-7") */ + fallbackLabel?: string; +} + +export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) { + const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId)); + + if (!issue) { + return ( + + {fallbackLabel ?? issueId.slice(0, 8)} + + ); + } + + return ( + + + {issue.identifier} + {issue.title} + + ); +} diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index 5ffd0d2c..fad98f2c 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -1,10 +1,11 @@ "use client"; import { useState } from "react"; -import { Bot, Lock, UserMinus } from "lucide-react"; +import { Lock, UserMinus } from "lucide-react"; import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { PropertyPicker, PickerItem, @@ -35,7 +36,7 @@ export function AssigneePicker({ const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorName, getActorInitials } = useActorName(); + const { getActorName } = useActorName(); const currentMember = members.find((m) => m.user_id === user?.id); const memberRole = currentMember?.role; @@ -70,19 +71,7 @@ export function AssigneePicker({ trigger={ customTrigger ? customTrigger : assigneeType && assigneeId ? ( <> -
- {assigneeType === "agent" ? ( - - ) : ( - getActorInitials(assigneeType, assigneeId) - )} -
+ {triggerLabel} ) : ( @@ -117,9 +106,7 @@ export function AssigneePicker({ setOpen(false); }} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} ))} @@ -145,9 +132,7 @@ export function AssigneePicker({ setOpen(false); }} > -
- -
+ {a.name} {a.visibility === "private" && ( diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b95662c4..e3fd5da8 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -1,20 +1,23 @@ "use client"; -import { useRef, useState } from "react"; -import { ArrowUp } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { useRef, useState, useEffect } from "react"; +import { ArrowUp, Loader2 } from "lucide-react"; import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; +import { FileUploadButton } from "@/components/common/file-upload-button"; import { ActorAvatar } from "@/components/common/actor-avatar"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; - onSubmit: (content: string) => Promise; + onSubmit: (content: string, attachmentIds?: string[]) => Promise; size?: "sm" | "default"; } @@ -23,6 +26,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -30,16 +34,39 @@ function ReplyInput({ size = "default", }: ReplyInputProps) { const editorRef = useRef(null); + const measureRef = useRef(null); + const attachmentIdsRef = useRef([]); const [isEmpty, setIsEmpty] = useState(true); + const [isExpanded, setIsExpanded] = useState(false); const [submitting, setSubmitting] = useState(false); + const { uploadWithToast, uploading } = useFileUpload(); + + useEffect(() => { + const el = measureRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setIsExpanded(entry.contentRect.height > 32); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const handleUpload = async (file: File) => { + const result = await uploadWithToast(file, { issueId }); + if (result) attachmentIdsRef.current.push(result.id); + return result; + }; const handleSubmit = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || submitting) return; setSubmitting(true); try { - await onSubmit(content); + const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined; + await onSubmit(content, ids); editorRef.current?.clearContent(); + attachmentIdsRef.current = []; setIsEmpty(true); } finally { setSubmitting(false); @@ -49,45 +76,54 @@ function ReplyInput({ const avatarSize = size === "sm" ? 22 : 28; return ( -
+
-
-
- setIsEmpty(!md.trim())} - onSubmit={handleSubmit} - debounceMs={100} - /> -
-
-
-
- -
+
+
+
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + onUploadFile={handleUpload} + debounceMs={100} + />
+
+ + editorRef.current?.insertFile(result.filename, result.link, isImage) + } + disabled={uploading} + /> + +
); diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 98530d9a..ab2eda6a 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -174,60 +174,34 @@ export function useIssueTimeline(issueId: string, userId?: string) { // --- Mutation functions --- const submitComment = useCallback( - async (content: string) => { + async (content: string, attachmentIds?: 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)), - ); + const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds); + 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); } }, - [issueId, userId, submitting], + [issueId, userId], ); const submitReply = useCallback( - async (parentId: string, content: string) => { + async (parentId: string, content: string, attachmentIds?: 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)), - ); + const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds); + 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/landing/components/faq-section.tsx b/apps/web/features/landing/components/faq-section.tsx new file mode 100644 index 00000000..e32aed38 --- /dev/null +++ b/apps/web/features/landing/components/faq-section.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useLocale } from "../i18n"; + +export function FAQSection() { + const { t } = useLocale(); + const [openIndex, setOpenIndex] = useState(null); + + return ( +
+
+
+

+ {t.faq.label} +

+

+ {t.faq.headline} +

+
+ +
+ {t.faq.items.map((faq, i) => ( +
+ +
+
+

+ {faq.answer} +

+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/features/landing/components/features-section.tsx b/apps/web/features/landing/components/features-section.tsx new file mode 100644 index 00000000..d04c5f4c --- /dev/null +++ b/apps/web/features/landing/components/features-section.tsx @@ -0,0 +1,1092 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Bot, + Brain, + Check, + CheckCircle2, + ChevronRight, + Cloud, + File, + FileText, + Folder, + FolderOpen, + Loader2, + Monitor, + Sparkles, + UserMinus, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ImageIcon } from "./shared"; +import { useLocale } from "../i18n"; +import type { LandingDict } from "../i18n"; +import { StatusIcon } from "@/features/issues/components/status-icon"; +import { PriorityIcon } from "@/features/issues/components/priority-icon"; +import { STATUS_CONFIG } from "@/features/issues/config/status"; +import { PRIORITY_CONFIG } from "@/features/issues/config/priority"; +import type { IssueStatus, IssuePriority } from "@/shared/types"; + +/* ------------------------------------------------------------------ */ +/* Mock ActorAvatar — mirrors the real ActorAvatar styling exactly */ +/* but uses hardcoded data instead of the workspace store */ +/* ------------------------------------------------------------------ */ + +function MockAvatar({ + type, + initials, + size = 20, +}: { + type: "member" | "agent"; + initials?: string; + size?: number; +}) { + return ( +
+ {type === "agent" ? ( + + ) : ( + initials + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Mock PropRow — mirrors the real PropRow from issue-detail */ +/* ------------------------------------------------------------------ */ + +function PropRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
+ {children} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Teammates feature visual */ +/* ------------------------------------------------------------------ */ + +const mockTimeline = [ + { + type: "activity" as const, + actorType: "member" as const, + initials: "AR", + name: "Alex Rivera", + action: "assigned to Claude", + time: "3:02 PM", + statusIcon: null, + }, + { + type: "activity" as const, + actorType: "agent" as const, + initials: "", + name: "Claude", + action: "changed status from Todo to In Progress", + time: "3:02 PM", + statusIcon: "in_progress" as const, + }, + { + type: "comment" as const, + actorType: "member" as const, + initials: "AR", + name: "Alex Rivera", + time: "10 min", + content: + "The current error responses are inconsistent across handlers — need a unified format with error codes.", + }, + { + type: "comment" as const, + actorType: "agent" as const, + initials: "", + name: "Claude", + time: "6 min", + content: + "I've standardized error responses across 14 handlers. Each error now includes a code, message, and request_id. PR #43 is ready for review.", + }, + { + type: "comment" as const, + actorType: "member" as const, + initials: "AR", + name: "Alex Rivera", + time: "3 min", + content: + "Looking good. Make sure to preserve the existing HTTP status codes — some of our frontend relies on specific codes like 409.", + }, +]; + +type Assignee = { + type: "member" | "agent" | null; + id: string | null; + name: string; + initials?: string; +}; + +const allAssignees: Assignee[] = [ + { type: null, id: null, name: "Unassigned" }, + { type: "member", id: "ar", name: "Alex Rivera", initials: "AR" }, + { type: "member", id: "sk", name: "Sarah Kim", initials: "SK" }, + { type: "agent", id: "claude", name: "Claude" }, + { type: "agent", id: "tina", name: "Tina-dev" }, +]; + +const statusCycle: IssueStatus[] = ["backlog", "todo", "in_progress", "in_review", "done"]; +const priorityCycle: IssuePriority[] = ["none", "low", "medium", "high", "urgent"]; + +function TeammatesVisual() { + const [status, setStatus] = useState("in_progress"); + const [priority, setPriority] = useState("medium"); + const [assignee, setAssignee] = useState(allAssignees[3]!); // Claude + const [pickerOpen, setPickerOpen] = useState(true); + const [statusOpen, setStatusOpen] = useState(false); + const [priorityOpen, setPriorityOpen] = useState(false); + + const cycleStatus = () => { + const idx = statusCycle.indexOf(status); + setStatus(statusCycle[(idx + 1) % statusCycle.length]!); + }; + + const cyclePriority = () => { + const idx = priorityCycle.indexOf(priority); + setPriority(priorityCycle[(idx + 1) % priorityCycle.length]!); + }; + + return ( +
+ {/* Header bar */} +
+
+ Multica Demo + + MUL-18 + + Refactor API error handling middleware +
+
+ +
+ {/* Main content area */} +
+

+ Refactor API error handling middleware +

+

+ Standardize error responses across all endpoints. +

+ +
+ +
+

Activity

+ Subscribe +
+ +
+ {mockTimeline.map((entry, i) => { + if (entry.type === "activity") { + return ( +
+
+ {entry.statusIcon ? ( + + ) : ( + + )} +
+
+ {entry.name} + {entry.action} + {entry.time} +
+
+ ); + } + + return ( +
+
+ + {entry.name} + {entry.time} +
+

+ {entry.content} +

+
+ ); + })} +
+
+ + {/* Properties sidebar */} +
+
+
+
+ + Properties +
+
+ {/* Status — clickable with dropdown */} +
+ + + + {statusOpen && ( +
+ {statusCycle.map((s) => ( + + ))} +
+ )} +
+ + {/* Priority — clickable with dropdown */} +
+ + + + {priorityOpen && ( +
+ {priorityCycle.map((p) => ( + + ))} +
+ )} +
+ + {/* Assignee — clickable to toggle picker */} + + + +
+
+ + {/* Assignee picker — togglable */} + {pickerOpen && ( +
+
+ Assign to... +
+
+ +
+
+ Members +
+
+ {allAssignees.filter((a) => a.type === "member").map((m) => ( + + ))} +
+
+ Agents +
+
+ {allAssignees.filter((a) => a.type === "agent").map((a) => ( + + ))} +
+
+ )} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Autonomous feature visual — agent live execution card */ +/* ------------------------------------------------------------------ */ + +const mockToolCalls = [ + { type: "thinking" as const, content: "Analyzing the error handling patterns across all 14 handler files…" }, + { type: "tool_use" as const, tool: "Read", summary: "server/internal/handler/issue.go" }, + { type: "tool_result" as const, preview: "func (h *IssueHandler) Create(w http.ResponseWriter, r *http.Request) { …" }, + { type: "tool_use" as const, tool: "Edit", summary: "server/internal/handler/issue.go — replace writeJSON error calls" }, + { type: "tool_result" as const, preview: "Updated 3 error responses to use writeError() helper" }, + { type: "thinking" as const, content: "Now checking handler/comment.go for the same inconsistent patterns…" }, + { type: "tool_use" as const, tool: "Read", summary: "server/internal/handler/comment.go" }, + { type: "tool_result" as const, preview: "func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request) { …" }, + { type: "tool_use" as const, tool: "Bash", summary: "go test ./internal/handler/ -run TestErrorResponses" }, + { type: "tool_result" as const, preview: "ok \tgithub.com/multica/server/internal/handler\t0.847s" }, +]; + +const mockTaskHistory = [ + { status: "completed" as const, title: "Set up error response types", duration: "2m 14s" }, + { status: "completed" as const, title: "Migrate issue handler", duration: "3m 41s" }, + { status: "running" as const, title: "Migrate comment handler", duration: "1m 22s" }, +]; + +function AutonomousVisual() { + const [expanded, setExpanded] = useState(null); + + return ( +
+ {/* Header bar */} +
+
+ Multica Demo + + MUL-18 + + Refactor API error handling middleware +
+
+ +
+ {/* Agent live card */} +
+ {/* Live card header */} +
+
+ +
+
+ + Agent is working +
+ 7m 17s + 10 tool calls +
+ + {/* Tool call timeline */} +
+ {mockToolCalls.map((item, i) => { + const isExpanded = expanded === i; + + if (item.type === "thinking") { + return ( + + ); + } + + if (item.type === "tool_use") { + return ( + + ); + } + + /* tool_result */ + return ( + + ); + })} +
+
+ + {/* Task run history */} +
+ Task execution history +
+ {mockTaskHistory.map((task, i) => ( +
+ {task.status === "completed" ? ( + + ) : ( + + )} + + {task.title} + + {task.duration} +
+ ))} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Skills feature visual — skill library + file browser */ +/* ------------------------------------------------------------------ */ + +const mockSkills = [ + { name: "Deploy to staging", description: "Run staging deploy pipeline", files: 3, selected: false }, + { name: "Write migration", description: "Generate and validate SQL migration", files: 4, selected: true }, + { name: "Review PR", description: "Code review with style guide checks", files: 2, selected: false }, + { name: "Write tests", description: "Generate unit and integration tests", files: 3, selected: false }, +]; + +const mockFileTree = [ + { name: "SKILL.md", isDir: false, depth: 0, icon: "md" as const }, + { name: "config", isDir: true, depth: 0, open: true }, + { name: "schema.sql", isDir: false, depth: 1, icon: "file" as const }, + { name: "templates", isDir: true, depth: 0, open: false }, +]; + +function SkillsVisual() { + const [selectedSkill, setSelectedSkill] = useState(1); + const [selectedFile, setSelectedFile] = useState("SKILL.md"); + + return ( +
+
+ {/* Skills list panel */} +
+
+ Skills + +
+
+ {mockSkills.map((skill, i) => ( + + ))} +
+
+ + {/* Skill detail */} +
+ {/* Skill header */} +
+ + {mockSkills[selectedSkill]?.name} + {mockSkills[selectedSkill]?.description} +
+ + {/* File browser */} +
+ {/* File tree */} +
+
+ Files +
+
+ {mockFileTree.map((f) => ( + + ))} +
+
+ + {/* File content viewer */} +
+
+ {selectedFile} +
+
+ {selectedFile === "SKILL.md" ? ( +
+ {/* Frontmatter */} +
+
+ name + write-migration + version + 1.2.0 + author + Alex Rivera +
+
+ {/* Content */} +
+

Write Migration

+

Generate a SQL migration file based on the requested schema changes. Validates against the current database state and generates both up and down migrations.

+

Steps

+
    +
  1. Analyze the current schema from migrations/
  2. +
  3. Generate migration SQL with proper ordering
  4. +
  5. Validate with sqlc compile
  6. +
  7. Run tests against a fresh database
  8. +
+
+
+ ) : ( +
+{`CREATE TABLE IF NOT EXISTS notifications (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id UUID NOT NULL REFERENCES users(id),
+  issue_id UUID REFERENCES issues(id),
+  type TEXT NOT NULL,
+  read BOOLEAN DEFAULT FALSE,
+  created_at TIMESTAMPTZ DEFAULT now()
+);`}
+                  
+ )} +
+
+
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Runtimes feature visual — agent dashboard with runtime status */ +/* ------------------------------------------------------------------ */ + +const runtimeStatusConfig = { + idle: { label: "Idle", color: "text-muted-foreground", dot: "bg-muted-foreground" }, + working: { label: "Working", color: "text-success", dot: "bg-success" }, + error: { label: "Error", color: "text-destructive", dot: "bg-destructive" }, + offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" }, +}; + +const mockRuntimeList = [ + { name: "MacBook Pro", mode: "local" as const, status: "online" as const, device: "arm64 / macOS 15.2", lastSeen: "Just now" }, + { name: "Cloud (Anthropic)", mode: "cloud" as const, status: "online" as const, device: "api.anthropic.com", lastSeen: "Just now" }, + { name: "Linux Server", mode: "local" as const, status: "offline" as const, device: "x86_64 / Ubuntu 24.04", lastSeen: "3h ago" }, +]; + +/* Mock usage data — deterministic seed values to avoid SSR/hydration mismatch */ +const USAGE_SEEDS = [ + [72, 38, 54, 12], [45, 22, 41, 8], [88, 44, 63, 15], [61, 31, 48, 10], + [93, 47, 58, 14], [55, 28, 39, 9], [79, 40, 52, 13], [67, 34, 46, 11], + [84, 42, 60, 14], [50, 25, 35, 7], [91, 46, 57, 13], [58, 29, 43, 10], + [76, 38, 51, 12], [63, 32, 44, 9], [87, 44, 59, 14], [52, 26, 37, 8], + [95, 48, 62, 15], [70, 35, 49, 11], [82, 41, 55, 13], [48, 24, 33, 7], + [89, 45, 61, 14], [65, 33, 47, 10], [78, 39, 53, 12], [56, 28, 40, 9], + [92, 46, 58, 14], [60, 30, 42, 8], [85, 43, 56, 13], [73, 37, 50, 11], + [80, 40, 54, 12], [68, 34, 45, 10], +]; +const mockUsageData = USAGE_SEEDS.map((s, i) => ({ + date: `2026-03-${String(i + 2).padStart(2, "0")}`, + input_tokens: s[0]! * 1000, + output_tokens: s[1]! * 1000, + cache_read_tokens: s[2]! * 1000, + cache_write_tokens: s[3]! * 1000, +})); + + +/* Heatmap color helper — same as real ActivityHeatmap */ +function getHeatmapColor(level: number): string { + const colors = [ + "var(--color-muted, hsl(var(--muted)))", + "hsl(var(--chart-3) / 0.3)", + "hsl(var(--chart-3) / 0.5)", + "hsl(var(--chart-3) / 0.75)", + "hsl(var(--chart-3) / 1)", + ]; + return colors[level] ?? colors[0]!; +} + +/* Generate heatmap cells — simplified version of real ActivityHeatmap */ +function buildHeatmapCells() { + const WEEKS = 13; + const cells: { week: number; day: number; level: number; date: string }[] = []; + const today = new Date(); + const todayDay = today.getDay(); + const startOffset = todayDay + (WEEKS - 1) * 7; + // Deterministic pseudo-random sequence based on cell index + const seed = [3, 1, 4, 2, 0, 3, 2, 4, 1, 3, 0, 2, 4, 1, 3, 2, 0, 4, 1, 3]; + + for (let i = 0; i <= startOffset; i++) { + const d = new Date(today); + d.setDate(today.getDate() - (startOffset - i)); + const week = Math.floor(i / 7); + const day = d.getDay(); + // Weekends (0=Sun, 6=Sat) get lower activity + const isWeekend = day === 0 || day === 6; + const level = isWeekend + ? seed[i % seed.length]! > 2 ? 1 : 0 + : seed[i % seed.length]!; + cells.push({ week, day, level, date: d.toISOString().slice(0, 10) }); + } + return cells; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +function DailyCostBars({ data }: { data: typeof mockUsageData }) { + const costs = data.map( + (d) => + (d.input_tokens * 3 + + d.output_tokens * 15 + + d.cache_read_tokens * 0.3 + + d.cache_write_tokens * 3.75) / + 1_000_000, + ); + const maxCost = Math.max(...costs); + const barW = 100 / data.length; + const chartH = 64; + return ( + + {costs.map((cost, i) => { + const h = maxCost > 0 ? (cost / maxCost) * (chartH - 4) : 0; + return ( + + ); + })} + + ); +} + +function RuntimesVisual() { + const [selectedRuntime, setSelectedRuntime] = useState(0); + const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d"); + const [heatmapCells, setHeatmapCells] = useState>([]); + + useEffect(() => { + setHeatmapCells(buildHeatmapCells()); + }, []); + + const totals = mockUsageData.reduce( + (acc, u) => ({ + input: acc.input + u.input_tokens, + output: acc.output + u.output_tokens, + cacheRead: acc.cacheRead + u.cache_read_tokens, + cacheWrite: acc.cacheWrite + u.cache_write_tokens, + }), + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + ); + + const CELL_SIZE = 10; + const CELL_GAP = 2; + const WEEKS = 13; + const labelWidth = 24; + const svgWidth = labelWidth + WEEKS * (CELL_SIZE + CELL_GAP); + const svgHeight = 12 + 7 * (CELL_SIZE + CELL_GAP); + + return ( +
+
+ {/* Runtime list */} +
+
+ Runtimes +
+
+ {mockRuntimeList.map((rt, i) => ( + + ))} +
+
+ + {/* Detail panel */} +
+ {/* Header */} +
+
+ {mockRuntimeList[selectedRuntime]?.mode === "cloud" ? ( + + ) : ( + + )} +
+ {mockRuntimeList[selectedRuntime]?.name} +
+ + {mockRuntimeList[selectedRuntime]?.status} +
+ {mockRuntimeList[selectedRuntime]?.device} +
+ + {/* Usage content */} +
+ {/* Time range + Token cards */} +
+
+ {(["7d", "30d", "90d"] as const).map((range) => ( + + ))} +
+
+ + {/* Token summary cards — same as real TokenCard */} +
+ {[ + { label: "Input", value: formatTokens(totals.input) }, + { label: "Output", value: formatTokens(totals.output) }, + { label: "Cache Read", value: formatTokens(totals.cacheRead) }, + { label: "Cache Write", value: formatTokens(totals.cacheWrite) }, + ].map((card) => ( +
+
{card.label}
+
{card.value}
+
+ ))} +
+ + {/* Charts row — Heatmap + Hourly bar */} +
+ {/* Activity Heatmap — mirrors real ActivityHeatmap */} +
+

Activity

+
+ + {["", "Mon", "", "Wed", "", "Fri", ""].map((label, i) => + label ? ( + + {label} + + ) : null, + )} + {heatmapCells.map((c, i) => ( + + ))} + +
+
+ Less + {[0, 1, 2, 3, 4].map((level) => ( +
+ ))} + More +
+
+ + {/* Daily Cost — SVG bar chart mirroring real DailyCostChart */} +
+

Daily Cost

+ +
+ Mar 18Mar 25Mar 31 +
+
+
+
+
+
+
+ ); +} + +function buildFeatures(t: LandingDict) { + const keys = ["teammates", "autonomous", "skills", "runtimes"] as const; + const visuals = [TeammatesVisual, AutonomousVisual, SkillsVisual, RuntimesVisual]; + const bgImages = [undefined, "/images/feature-bg-2.jpg", "/images/feature-bg-3.jpg", "/images/feature-bg-4.jpg"]; + + return keys.map((key, i) => ({ + ...t.features[key], + visual: visuals[i]!, + bgImage: bgImages[i], + })); +} + +export function FeaturesSection() { + const { t } = useLocale(); + const features = buildFeatures(t); + const [activeIndex, setActiveIndex] = useState(0); + const panelRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const idx = Number(entry.target.getAttribute("data-index")); + if (!isNaN(idx)) setActiveIndex(idx); + } + } + }, + { rootMargin: "-20% 0px -60% 0px", threshold: 0 }, + ); + + panelRefs.current.forEach((el) => { + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, []); + + const scrollToPanel = (index: number) => { + panelRefs.current[index]?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }; + + return ( +
+
+
+ {/* Sticky left nav */} + + + {/* Scrollable feature panels */} +
+ {features.map((feature, i) => ( +
{ + panelRefs.current[i] = el; + }} + data-index={i} + className={cn( + "py-20 lg:py-28", + i < features.length - 1 && "border-b border-[#0a0d12]/8", + )} + > + {/* Title + description */} +

+ {feature.title} +

+

+ {feature.description} +

+ + {/* Visual */} +
+ {feature.visual ? ( +
+
+ +
+
+ ) : ( +
+
+
+
+
+ +
+

+ {feature.label.toLowerCase()} visual +

+
+
+
+ )} +
+ + {/* Feature cards */} +
+ {feature.cards.map((card) => ( +
+

+ {card.title} +

+

+ {card.description} +

+
+ ))} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/features/landing/components/how-it-works-section.tsx b/apps/web/features/landing/components/how-it-works-section.tsx new file mode 100644 index 00000000..0b3d3b83 --- /dev/null +++ b/apps/web/features/landing/components/how-it-works-section.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useLocale } from "../i18n"; +import { GitHubMark, githubUrl, heroButtonClassName } from "./shared"; + +export function HowItWorksSection() { + const { t } = useLocale(); + + return ( +
+
+

+ {t.howItWorks.label} +

+

+ {t.howItWorks.headlineMain} +
+ {t.howItWorks.headlineFaded} +

+ +
+ {t.howItWorks.steps.map((step, i) => ( +
+ + {String(i + 1).padStart(2, "0")} + +

+ {step.title} +

+

+ {step.description} +

+
+ ))} +
+ +
+ + {t.howItWorks.cta} + + + + {t.howItWorks.ctaGithub} + +
+
+
+ ); +} diff --git a/apps/web/features/landing/components/landing-footer.tsx b/apps/web/features/landing/components/landing-footer.tsx new file mode 100644 index 00000000..daeb18ba --- /dev/null +++ b/apps/web/features/landing/components/landing-footer.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { MulticaIcon } from "@/components/multica-icon"; +import { cn } from "@/lib/utils"; +import { useLocale, locales, localeLabels } from "../i18n"; + +export function LandingFooter() { + const { t, locale, setLocale } = useLocale(); + const groups = Object.values(t.footer.groups); + + return ( +
+
+ {/* Top: CTA + link columns */} +
+ {/* Left — newsletter / CTA */} +
+ + + + multica + + +

+ {t.footer.tagline} +

+
+ + {t.footer.cta} + +
+
+ + {/* Right — link columns */} +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ))} +
+
+ + {/* Bottom: copyright + language switcher */} +
+

+ {t.footer.copyright.replace( + "{year}", + String(new Date().getFullYear()), + )} +

+
+ {locales.map((l, i) => ( + + ))} +
+
+ + {/* Giant logo */} +
+
+ + + multica + +
+
+
+
+ ); +} diff --git a/apps/web/features/landing/components/landing-header.tsx b/apps/web/features/landing/components/landing-header.tsx new file mode 100644 index 00000000..0a9f4960 --- /dev/null +++ b/apps/web/features/landing/components/landing-header.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { MulticaIcon } from "@/components/multica-icon"; +import { cn } from "@/lib/utils"; +import { useLocale } from "../i18n"; +import { GitHubMark, githubUrl, headerButtonClassName } from "./shared"; + +export function LandingHeader({ + variant = "dark", +}: { + variant?: "dark" | "light"; +}) { + const { t } = useLocale(); + + return ( +
+
+ + + + multica + + + +
+ + + {t.header.github} + + + {t.header.login} + +
+
+
+ ); +} diff --git a/apps/web/features/landing/components/landing-hero.tsx b/apps/web/features/landing/components/landing-hero.tsx new file mode 100644 index 00000000..5e607e98 --- /dev/null +++ b/apps/web/features/landing/components/landing-hero.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useLocale } from "../i18n"; +import { + ClaudeCodeLogo, + CodexLogo, + GitHubMark, + githubUrl, + heroButtonClassName, +} from "./shared"; + +export function LandingHero() { + const { t } = useLocale(); + + return ( +
+ + +
+
+
+

+ {t.hero.headlineLine1} +
+ {t.hero.headlineLine2} +

+ +

+ {t.hero.subheading} +

+ +
+ + {t.hero.cta} + + + + GitHub + +
+
+ +
+ + {t.hero.worksWith} + +
+
+ + Claude Code +
+
+ + Codex +
+
+
+ +
+ +
+
+
+
+ ); +} + +function LandingBackdrop() { + return ( +
+ +
+ ); +} + +function ProductImage({ alt }: { alt: string }) { + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {alt} +
+
+ ); +} diff --git a/apps/web/features/landing/components/multica-landing.tsx b/apps/web/features/landing/components/multica-landing.tsx new file mode 100644 index 00000000..a608a7df --- /dev/null +++ b/apps/web/features/landing/components/multica-landing.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { LandingHeader } from "./landing-header"; +import { LandingHero } from "./landing-hero"; +import { FeaturesSection } from "./features-section"; +import { HowItWorksSection } from "./how-it-works-section"; +import { OpenSourceSection } from "./open-source-section"; +import { FAQSection } from "./faq-section"; +import { LandingFooter } from "./landing-footer"; + +export function MulticaLanding() { + return ( + <> +
+ + +
+ + + + + + + + ); +} diff --git a/apps/web/features/landing/components/open-source-section.tsx b/apps/web/features/landing/components/open-source-section.tsx new file mode 100644 index 00000000..809aff35 --- /dev/null +++ b/apps/web/features/landing/components/open-source-section.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { useLocale } from "../i18n"; +import { GitHubMark, githubUrl } from "./shared"; + +export function OpenSourceSection() { + const { t } = useLocale(); + + return ( +
+
+
+ {/* Left column — heading + CTA */} +
+

+ {t.openSource.label} +

+

+ {t.openSource.headlineLine1} +
+ {t.openSource.headlineLine2} +

+

+ {t.openSource.description} +

+
+ + + {t.openSource.cta} + +
+
+ + {/* Right column — highlight grid */} +
+
+ {t.openSource.highlights.map((item) => ( +
+

+ {item.title} +

+

+ {item.description} +

+
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/web/features/landing/components/shared.tsx b/apps/web/features/landing/components/shared.tsx new file mode 100644 index 00000000..d1fd323e --- /dev/null +++ b/apps/web/features/landing/components/shared.tsx @@ -0,0 +1,87 @@ +import { cn } from "@/lib/utils"; + +export const githubUrl = "https://github.com/multica-ai/multica"; + +export function GitHubMark({ className }: { className?: string }) { + return ( + + ); +} + +export function ImageIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function ClaudeCodeLogo({ className }: { className?: string }) { + return ( + + ); +} + +export function CodexLogo({ className }: { className?: string }) { + return ( + + ); +} + +export function headerButtonClassName( + tone: "ghost" | "solid", + variant: "dark" | "light" = "dark", +) { + return cn( + "inline-flex items-center justify-center gap-2 rounded-[11px] px-4 py-2.5 text-[13px] font-semibold transition-colors", + variant === "dark" + ? tone === "solid" + ? "bg-white text-[#0a0d12] hover:bg-white/92" + : "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24" + : tone === "solid" + ? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/88" + : "border border-[#0a0d12]/12 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/5", + ); +} + +export function heroButtonClassName(tone: "ghost" | "solid") { + return cn( + "inline-flex items-center justify-center gap-2 rounded-[12px] px-5 py-3 text-[14px] font-semibold transition-colors", + tone === "solid" + ? "bg-white text-[#0a0d12] hover:bg-white/92" + : "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24", + ); +} diff --git a/apps/web/features/landing/i18n/context.tsx b/apps/web/features/landing/i18n/context.tsx new file mode 100644 index 00000000..54067723 --- /dev/null +++ b/apps/web/features/landing/i18n/context.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { createContext, useContext, useState, useCallback } from "react"; +import { en } from "./en"; +import { zh } from "./zh"; +import type { LandingDict, Locale } from "./types"; + +const dictionaries: Record = { en, zh }; + +const STORAGE_KEY = "multica-locale"; + +function getInitialLocale(): Locale { + if (typeof window === "undefined") return "en"; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "en" || stored === "zh") return stored; + // Detect from browser language + const lang = navigator.language; + if (lang.startsWith("zh")) return "zh"; + return "en"; +} + +type LocaleContextValue = { + locale: Locale; + t: LandingDict; + setLocale: (locale: Locale) => void; +}; + +const LocaleContext = createContext(null); + +export function LocaleProvider({ children }: { children: React.ReactNode }) { + const [locale, setLocaleState] = useState(getInitialLocale); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + localStorage.setItem(STORAGE_KEY, l); + }, []); + + return ( + + {children} + + ); +} + +export function useLocale() { + const ctx = useContext(LocaleContext); + if (!ctx) throw new Error("useLocale must be used within LocaleProvider"); + return ctx; +} diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts new file mode 100644 index 00000000..dea7f131 --- /dev/null +++ b/apps/web/features/landing/i18n/en.ts @@ -0,0 +1,333 @@ +import { githubUrl } from "../components/shared"; +import type { LandingDict } from "./types"; + +export const en: LandingDict = { + header: { + github: "GitHub", + login: "Log in", + }, + + hero: { + headlineLine1: "Your next 10 hires", + headlineLine2: "won\u2019t be human.", + subheading: + "Multica is an open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.", + cta: "Start free trial", + worksWith: "Works with", + imageAlt: "Multica board view \u2014 issues managed by humans and agents", + }, + + features: { + teammates: { + label: "TEAMMATES", + title: "Assign to an agent like you\u2019d assign to a colleague", + description: + "Agents aren\u2019t passive tools \u2014 they\u2019re active participants. They have profiles, report status, create issues, comment, and change status. Your activity feed shows humans and agents working side by side.", + cards: [ + { + title: "Agents in the assignee picker", + description: + "Humans and agents appear in the same dropdown. Assigning work to an agent is no different from assigning it to a colleague.", + }, + { + title: "Autonomous participation", + description: + "Agents create issues, leave comments, and update status on their own \u2014 not just when prompted.", + }, + { + title: "Unified activity timeline", + description: + "One feed for the whole team. Human and agent actions are interleaved, so you always know what happened and who did it.", + }, + ], + }, + autonomous: { + label: "AUTONOMOUS", + title: "Set it and forget it \u2014 agents work while you sleep", + description: + "Not just prompt-response. Full task lifecycle management: enqueue, claim, start, complete or fail. Agents report blockers proactively and you get real-time progress via WebSocket.", + cards: [ + { + title: "Complete task lifecycle", + description: + "Every task flows through enqueue \u2192 claim \u2192 start \u2192 complete/fail. No silent failures \u2014 every transition is tracked and broadcast.", + }, + { + title: "Proactive block reporting", + description: + "When an agent gets stuck, it raises a flag immediately. No more checking back hours later to find nothing happened.", + }, + { + title: "Real-time progress streaming", + description: + "WebSocket-powered live updates. Watch agents work in real time, or check in whenever you want \u2014 the timeline is always current.", + }, + ], + }, + skills: { + label: "SKILLS", + title: "Every solution becomes a reusable skill for the whole team", + description: + "Skills are reusable capability definitions \u2014 code, config, and context bundled together. Write a skill once, and every agent on your team can use it. Your skill library compounds over time.", + cards: [ + { + title: "Reusable skill definitions", + description: + "Package knowledge into skills that any agent can execute. Deploy to staging, write migrations, review PRs \u2014 all codified.", + }, + { + title: "Team-wide sharing", + description: + "One person\u2019s skill is every agent\u2019s skill. Build once, benefit everywhere across your team.", + }, + { + title: "Compound growth", + description: + "Day 1: you teach an agent to deploy. Day 30: every agent deploys, writes tests, and does code review. Your team\u2019s capabilities grow exponentially.", + }, + ], + }, + runtimes: { + label: "RUNTIMES", + title: "One dashboard for all your compute", + description: + "Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.", + cards: [ + { + title: "Unified runtime panel", + description: + "Local daemons and cloud runtimes in one view. No context switching between different management interfaces.", + }, + { + title: "Real-time monitoring", + description: + "Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.", + }, + { + title: "Auto-detection & plug-and-play", + description: + "Multica detects available CLIs like Claude Code and Codex automatically. Connect a machine, and it\u2019s ready to work.", + }, + ], + }, + }, + + howItWorks: { + label: "Get started", + headlineMain: "Hire your first AI employee", + headlineFaded: "in the next hour.", + steps: [ + { + title: "Sign up & create your workspace", + description: + "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.", + }, + { + title: "Install the CLI & connect your machine", + description: + "Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code and Codex on your machine \u2014 plug in and go.", + }, + { + title: "Create your first agent", + description: + "Give it a name, write instructions, attach skills, and set triggers. Choose when it activates: on assignment, on comment, or on mention.", + }, + { + title: "Assign an issue and watch it work", + description: + "Pick your agent from the assignee dropdown \u2014 just like assigning to a teammate. The task is queued, claimed, and executed automatically. Watch progress in real time.", + }, + ], + cta: "Get started", + ctaGithub: "View on GitHub", + }, + + openSource: { + label: "Open source", + headlineLine1: "Open source", + headlineLine2: "for all.", + description: + "Multica is fully open source. Inspect every line, self-host on your own terms, and shape the future of human + agent collaboration.", + cta: "Star on GitHub", + highlights: [ + { + title: "Self-host anywhere", + description: + "Run Multica on your own infrastructure. Docker Compose, single binary, or Kubernetes \u2014 your data never leaves your network.", + }, + { + title: "No vendor lock-in", + description: + "Bring your own LLM provider, swap agent backends, extend the API. You own the stack, top to bottom.", + }, + { + title: "Transparent by default", + description: + "Every line of code is auditable. See exactly how your agents make decisions, how tasks are routed, and where your data flows.", + }, + { + title: "Community-driven", + description: + "Built with the community, not just for it. Contribute skills, integrations, and agent backends that benefit everyone.", + }, + ], + }, + + faq: { + label: "FAQ", + headline: "Questions & answers.", + items: [ + { + question: "What coding agents does Multica support?", + answer: + "Multica currently supports Claude Code and OpenAI Codex out of the box. The daemon auto-detects whichever CLIs you have installed. More backends are on the roadmap \u2014 and since it\u2019s open source, you can add your own.", + }, + { + question: "Do I need to self-host, or is there a cloud version?", + answer: + "Both. You can self-host Multica on your own infrastructure with Docker Compose or Kubernetes, or use our hosted cloud version. Your data, your choice.", + }, + { + question: + "How is this different from just using Claude Code or Codex directly?", + answer: + "Coding agents are great at executing. Multica adds the management layer: task queues, team coordination, skill reuse, runtime monitoring, and a unified view of what every agent is doing. Think of it as the project manager for your agents.", + }, + { + question: "Can agents work on long-running tasks autonomously?", + answer: + "Yes. Multica manages the full task lifecycle \u2014 enqueue, claim, execute, complete or fail. Agents report blockers proactively and stream progress in real time. You can check in whenever you want or let them run overnight.", + }, + { + question: "Is my code safe? Where does agent execution happen?", + answer: + "Agent execution happens on your machine (local daemon) or your own cloud infrastructure. Code never passes through Multica servers. The platform only coordinates task state and broadcasts events.", + }, + { + question: "How many agents can I run?", + answer: + "As many as your hardware supports. Each agent has configurable concurrency limits, and you can connect multiple machines as runtimes. There are no artificial caps in the open source version.", + }, + ], + }, + + footer: { + tagline: + "Project management for human + agent teams. Open source, self-hostable, built for the future of work.", + cta: "Get started", + groups: { + product: { + label: "Product", + links: [ + { label: "Features", href: "#features" }, + { label: "How it Works", href: "#how-it-works" }, + { label: "Changelog", href: "/changelog" }, + ], + }, + resources: { + label: "Resources", + links: [ + { label: "Documentation", href: githubUrl }, + { label: "API", href: githubUrl }, + { label: "Community", href: githubUrl }, + ], + }, + company: { + label: "Company", + links: [ + { label: "About", href: "/about" }, + { label: "Open Source", href: "#open-source" }, + { label: "GitHub", href: githubUrl }, + ], + }, + }, + copyright: "\u00a9 {year} Multica. All rights reserved.", + }, + + about: { + title: "About Multica", + nameLine: { + prefix: "Multica \u2014 ", + mul: "Mul", + tiplexed: "tiplexed ", + i: "I", + nformationAnd: "nformation and ", + c: "C", + omputing: "omputing ", + a: "A", + gent: "gent.", + }, + paragraphs: [ + "The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing \u2014 letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.", + "We think the same inflection is happening again. For decades, software teams have been single-threaded \u2014 one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the \u201cusers\u201d multiplexing the system are both humans and autonomous agents.", + "In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code \u2014 just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.", + "Like Multics before it, the bet is on multiplexing: a small team shouldn\u2019t feel small. With the right system, two engineers and a fleet of agents can move like twenty.", + "The platform is fully open source and self-hostable. Your data stays on your infrastructure. Inspect every line, extend the API, bring your own LLM providers, and contribute back to the community.", + ], + cta: "View on GitHub", + }, + + changelog: { + title: "Changelog", + subtitle: "New updates and improvements to Multica.", + entries: [ + { + version: "0.1.3", + date: "2026-03-31", + title: "Agent Intelligence", + changes: [ + "Trigger agents via @mention in comments", + "Stream live agent output to issue detail page", + "Rich text editor \u2014 mentions, link paste, emoji reactions, collapsible threads", + "File upload with S3 + CloudFront signed URLs and attachment tracking", + "Agent-driven repo checkout with bare clone cache for task isolation", + "Batch operations for issue list view", + "Daemon authentication and security hardening", + ], + }, + { + version: "0.1.2", + date: "2026-03-28", + title: "Collaboration", + changes: [ + "Email verification login and browser-based CLI auth", + "Multi-workspace daemon with hot-reload", + "Runtime dashboard with usage charts and activity heatmaps", + "Subscriber-driven notification model replacing hardcoded triggers", + "Unified activity timeline with threaded comment replies", + "Kanban board redesign with drag sorting, filters, and display settings", + "Human-readable issue identifiers (e.g. JIA-1)", + "Skill import from ClawHub and Skills.sh", + ], + }, + { + version: "0.1.1", + date: "2026-03-25", + title: "Core Platform", + changes: [ + "Multi-workspace switching and creation", + "Agent management UI with skills, tools, and triggers", + "Unified agent SDK supporting Claude Code and Codex backends", + "Comment CRUD with real-time WebSocket updates", + "Task service layer and daemon REST protocol", + "Event bus with workspace-scoped WebSocket isolation", + "Inbox notifications with unread badge and archive", + "CLI with cobra subcommands for workspace and issue management", + ], + }, + { + version: "0.1.0", + date: "2026-03-22", + title: "Foundation", + changes: [ + "Go backend with REST API, JWT auth, and real-time WebSocket", + "Next.js frontend with Linear-inspired UI", + "Issues with board and list views and drag-and-drop kanban", + "Agents, Inbox, and Settings pages", + "One-click setup, migration CLI, and seed tool", + "Comprehensive test suite \u2014 Go unit/integration, Vitest, Playwright E2E", + ], + }, + ], + }, +}; diff --git a/apps/web/features/landing/i18n/index.ts b/apps/web/features/landing/i18n/index.ts new file mode 100644 index 00000000..33e9d563 --- /dev/null +++ b/apps/web/features/landing/i18n/index.ts @@ -0,0 +1,3 @@ +export { LocaleProvider, useLocale } from "./context"; +export { locales, localeLabels } from "./types"; +export type { Locale, LandingDict } from "./types"; diff --git a/apps/web/features/landing/i18n/types.ts b/apps/web/features/landing/i18n/types.ts new file mode 100644 index 00000000..a0249780 --- /dev/null +++ b/apps/web/features/landing/i18n/types.ts @@ -0,0 +1,95 @@ +export type Locale = "en" | "zh"; + +export const locales: Locale[] = ["en", "zh"]; + +export const localeLabels: Record = { + en: "EN", + zh: "\u4e2d\u6587", +}; + +type FeatureSection = { + label: string; + title: string; + description: string; + cards: { title: string; description: string }[]; +}; + +type FooterGroup = { + label: string; + links: { label: string; href: string }[]; +}; + +export type LandingDict = { + header: { github: string; login: string }; + hero: { + headlineLine1: string; + headlineLine2: string; + subheading: string; + cta: string; + worksWith: string; + imageAlt: string; + }; + features: { + teammates: FeatureSection; + autonomous: FeatureSection; + skills: FeatureSection; + runtimes: FeatureSection; + }; + howItWorks: { + label: string; + headlineMain: string; + headlineFaded: string; + steps: { title: string; description: string }[]; + cta: string; + ctaGithub: string; + }; + openSource: { + label: string; + headlineLine1: string; + headlineLine2: string; + description: string; + cta: string; + highlights: { title: string; description: string }[]; + }; + faq: { + label: string; + headline: string; + items: { question: string; answer: string }[]; + }; + footer: { + tagline: string; + cta: string; + groups: { + product: FooterGroup; + resources: FooterGroup; + company: FooterGroup; + }; + copyright: string; + }; + about: { + title: string; + nameLine: { + prefix: string; + mul: string; + tiplexed: string; + i: string; + nformationAnd: string; + c: string; + omputing: string; + a: string; + gent: string; + }; + paragraphs: string[]; + cta: string; + }; + changelog: { + title: string; + subtitle: string; + entries: { + version: string; + date: string; + title: string; + changes: string[]; + }[]; + }; +}; diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts new file mode 100644 index 00000000..914b3d0c --- /dev/null +++ b/apps/web/features/landing/i18n/zh.ts @@ -0,0 +1,333 @@ +import { githubUrl } from "../components/shared"; +import type { LandingDict } from "./types"; + +export const zh: LandingDict = { + header: { + github: "GitHub", + login: "\u767b\u5f55", + }, + + hero: { + headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5", + headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002", + subheading: + "Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002", + cta: "\u514d\u8d39\u5f00\u59cb", + worksWith: "\u652f\u6301", + imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1", + }, + + features: { + teammates: { + label: "\u56e2\u961f\u534f\u4f5c", + title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 Agent", + description: + "Agent \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c Agent \u5e76\u80a9\u5de5\u4f5c\u3002", + cards: [ + { + title: "Agent \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d", + description: + "\u4eba\u7c7b\u548c Agent \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 Agent \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002", + }, + { + title: "\u81ea\u4e3b\u53c2\u4e0e", + description: + "Agent \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002", + }, + { + title: "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf", + description: + "\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c Agent \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002", + }, + ], + }, + autonomous: { + label: "\u81ea\u4e3b\u6267\u884c", + title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014Agent \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c", + description: + "\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002", + cards: [ + { + title: "\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f", + description: + "\u6bcf\u4e2a\u4efb\u52a1\u7ecf\u5386\u5165\u961f \u2192 \u9886\u53d6 \u2192 \u542f\u52a8 \u2192 \u5b8c\u6210/\u5931\u8d25\u3002\u6ca1\u6709\u65e0\u58f0\u5931\u8d25\u2014\u2014\u6bcf\u6b21\u72b6\u6001\u8f6c\u6362\u90fd\u88ab\u8ddf\u8e2a\u548c\u5e7f\u64ad\u3002", + }, + { + title: "\u4e3b\u52a8\u62a5\u544a\u963b\u585e", + description: + "\u5f53 Agent \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002", + }, + { + title: "\u5b9e\u65f6\u8fdb\u5ea6\u63a8\u9001", + description: + "\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b Agent \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002", + }, + ], + }, + skills: { + label: "\u6280\u80fd\u5e93", + title: "\u6bcf\u4e2a\u89e3\u51b3\u65b9\u6848\u90fd\u6210\u4e3a\u5168\u56e2\u961f\u53ef\u590d\u7528\u7684\u6280\u80fd", + description: + "\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a Agent \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002", + cards: [ + { + title: "\u53ef\u590d\u7528\u7684\u6280\u80fd\u5b9a\u4e49", + description: + "\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 Agent \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002", + }, + { + title: "\u5168\u56e2\u961f\u5171\u4eab", + description: + "\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a Agent \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002", + }, + { + title: "\u590d\u5408\u589e\u957f", + description: + "\u7b2c 1 \u5929\uff1a\u4f60\u6559 Agent \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a Agent \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002", + }, + ], + }, + runtimes: { + label: "\u8fd0\u884c\u65f6", + title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b", + description: + "\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u5730 CLI\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002", + cards: [ + { + title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f", + description: + "\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\u5728\u540c\u4e00\u89c6\u56fe\u4e2d\u3002\u65e0\u9700\u5728\u4e0d\u540c\u7ba1\u7406\u754c\u9762\u4e4b\u95f4\u5207\u6362\u3002", + }, + { + title: "\u5b9e\u65f6\u76d1\u63a7", + description: + "\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u968f\u65f6\u4e86\u89e3\u4f60\u7684\u7b97\u529b\u5728\u505a\u4ec0\u4e48\u3002", + }, + { + title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528", + description: + "Multica \u81ea\u52a8\u68c0\u6d4b Claude Code \u548c Codex \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002", + }, + ], + }, + }, + + howItWorks: { + label: "\u5f00\u59cb\u4f7f\u7528", + headlineMain: "\u62db\u52df\u4f60\u7684\u7b2c\u4e00\u4e2a AI \u5458\u5de5", + headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002", + steps: [ + { + title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a", + description: + "\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002", + }, + { + title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668", + description: + "\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code \u548c Codex\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002", + }, + { + title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent", + description: + "\u7ed9\u5b83\u8d77\u4e2a\u540d\u5b57\uff0c\u5199\u597d\u6307\u4ee4\uff0c\u9644\u52a0\u6280\u80fd\uff0c\u8bbe\u7f6e\u89e6\u53d1\u5668\u3002\u9009\u62e9\u5b83\u4f55\u65f6\u6fc0\u6d3b\uff1a\u88ab\u6307\u6d3e\u65f6\u3001\u6709\u8bc4\u8bba\u65f6\u3001\u88ab @\u63d0\u53ca\u65f6\u3002", + }, + { + title: "\u6307\u6d3e\u4e00\u4e2a Issue \u5e76\u89c2\u5bdf\u5b83\u5de5\u4f5c", + description: + "\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 Agent\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002", + }, + ], + cta: "\u5f00\u59cb\u4f7f\u7528", + ctaGithub: "\u5728 GitHub \u4e0a\u67e5\u770b", + }, + + openSource: { + label: "\u5f00\u6e90", + headlineLine1: "\u5f00\u6e90", + headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002", + description: + "Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + Agent \u534f\u4f5c\u7684\u672a\u6765\u3002", + cta: "\u5728 GitHub \u4e0a Star", + highlights: [ + { + title: "\u968f\u5904\u81ea\u6258\u7ba1", + description: + "\u5728\u4f60\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u8fd0\u884c Multica\u3002Docker Compose\u3001\u5355\u4e2a\u4e8c\u8fdb\u5236\u6216 Kubernetes\u2014\u2014\u4f60\u7684\u6570\u636e\u6c38\u8fdc\u4e0d\u4f1a\u79bb\u5f00\u4f60\u7684\u7f51\u7edc\u3002", + }, + { + title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a", + description: + "\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 Agent \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002", + }, + { + title: "\u9ed8\u8ba4\u900f\u660e", + description: + "\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 Agent \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002", + }, + { + title: "\u793e\u533a\u9a71\u52a8", + description: + "\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c Agent \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002", + }, + ], + }, + + faq: { + label: "\u5e38\u89c1\u95ee\u9898", + headline: "\u95ee\u4e0e\u7b54\u3002", + items: [ + { + question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f", + answer: + "Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code \u548c OpenAI Codex\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u66f4\u591a\u540e\u7aef\u5728\u8def\u7ebf\u56fe\u4e0a\u2014\u2014\u800c\u4e14\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u3002", + }, + { + question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f", + answer: + "\u4e24\u8005\u90fd\u6709\u3002\u4f60\u53ef\u4ee5\u7528 Docker Compose \u6216 Kubernetes \u5728\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u81ea\u6258\u7ba1 Multica\uff0c\u4e5f\u53ef\u4ee5\u4f7f\u7528\u6211\u4eec\u7684\u6258\u7ba1\u4e91\u7248\u672c\u3002\u4f60\u7684\u6570\u636e\uff0c\u4f60\u9009\u62e9\u3002", + }, + { + question: + "\u8fd9\u548c\u76f4\u63a5\u7528 Claude Code \u6216 Codex \u6709\u4ec0\u4e48\u533a\u522b\uff1f", + answer: + "\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002", + }, + { + question: "Agent \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f", + answer: + "\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002", + }, + { + question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1fAgent \u5728\u54ea\u91cc\u6267\u884c\uff1f", + answer: + "Agent \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002", + }, + { + question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a Agent\uff1f", + answer: + "\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a Agent \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002", + }, + ], + }, + + footer: { + tagline: + "\u4eba\u7c7b + Agent \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002", + cta: "\u5f00\u59cb\u4f7f\u7528", + groups: { + product: { + label: "\u4ea7\u54c1", + links: [ + { label: "\u529f\u80fd\u7279\u6027", href: "#features" }, + { label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" }, + { label: "\u66f4\u65b0\u65e5\u5fd7", href: "/changelog" }, + ], + }, + resources: { + label: "\u8d44\u6e90", + links: [ + { label: "\u6587\u6863", href: githubUrl }, + { label: "API", href: githubUrl }, + { label: "\u793e\u533a", href: githubUrl }, + ], + }, + company: { + label: "\u5173\u4e8e", + links: [ + { label: "\u5173\u4e8e\u6211\u4eec", href: "/about" }, + { label: "\u5f00\u6e90", href: "#open-source" }, + { label: "GitHub", href: githubUrl }, + ], + }, + }, + copyright: "\u00a9 {year} Multica. \u4fdd\u7559\u6240\u6709\u6743\u5229\u3002", + }, + + about: { + title: "\u5173\u4e8e Multica", + nameLine: { + prefix: "Multica\u2014\u2014", + mul: "Mul", + tiplexed: "tiplexed ", + i: "I", + nformationAnd: "nformation and ", + c: "C", + omputing: "omputing ", + a: "A", + gent: "gent\u3002", + }, + paragraphs: [ + "\u8fd9\u4e2a\u540d\u5b57\u662f\u5728\u5411 20 \u4e16\u7eaa 60 \u5e74\u4ee3\u5177\u6709\u5f00\u521b\u610f\u4e49\u7684\u64cd\u4f5c\u7cfb\u7edf Multics \u81f4\u610f\u3002Multics \u9996\u521b\u4e86\u5206\u65f6\u7cfb\u7edf\uff0c\u8ba9\u591a\u4e2a\u7528\u6237\u80fd\u591f\u5171\u4eab\u540c\u4e00\u53f0\u673a\u5668\uff0c\u540c\u65f6\u53c8\u50cf\u5404\u81ea\u72ec\u5360\u5b83\u4e00\u6837\u4f7f\u7528\u3002Unix \u5219\u662f\u5728\u6709\u610f\u7b80\u5316 Multics \u7684\u57fa\u7840\u4e0a\u8bde\u751f\u7684\uff0c\u5f3a\u8c03\u4e00\u4e2a\u7528\u6237\u3001\u4e00\u4e2a\u4efb\u52a1\u3001\u4e00\u79cd\u4f18\u96c5\u7684\u54f2\u5b66\u3002", + "\u6211\u4eec\u8ba4\u4e3a\uff0c\u7c7b\u4f3c\u7684\u8f6c\u6298\u70b9\u6b63\u5728\u518d\u6b21\u51fa\u73b0\u3002\u51e0\u5341\u5e74\u6765\uff0c\u8f6f\u4ef6\u56e2\u961f\u4e00\u76f4\u5904\u4e8e\u4e00\u79cd\u5355\u7ebf\u7a0b\u7684\u5de5\u4f5c\u6a21\u5f0f\uff0c\u4e00\u4e2a\u5de5\u7a0b\u5e08\u5904\u7406\u4e00\u4e2a\u4efb\u52a1\uff0c\u4e00\u6b21\u53ea\u4e13\u6ce8\u4e8e\u4e00\u4e2a\u4e0a\u4e0b\u6587\u3002AI agents \u6539\u53d8\u4e86\u8fd9\u4e2a\u7b49\u5f0f\u3002Multica \u5c06\u201c\u5206\u65f6\u201d\u91cd\u65b0\u5e26\u56de\u8fd9\u4e2a\u65f6\u4ee3\uff0c\u53ea\u4e0d\u8fc7\u4eca\u5929\u5728\u7cfb\u7edf\u4e2d\u8fdb\u884c\u591a\u8def\u590d\u7528\u7684\u201c\u7528\u6237\u201d\uff0c\u65e2\u5305\u62ec\u4eba\u7c7b\uff0c\u4e5f\u5305\u62ec\u81ea\u4e3b\u4ee3\u7406\u3002", + "\u5728 Multica \u4e2d\uff0cagents \u662f\u4e00\u7ea7\u56e2\u961f\u6210\u5458\u3002\u5b83\u4eec\u4f1a\u88ab\u5206\u914d issue\uff0c\u6c47\u62a5\u8fdb\u5c55\uff0c\u63d0\u51fa\u963b\u585e\uff0c\u5e76\u4ea4\u4ed8\u4ee3\u7801\uff0c\u5c31\u50cf\u4eba\u7c7b\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u5206\u914d\u3001\u6d3b\u52a8\u65f6\u95f4\u7ebf\u3001\u4efb\u52a1\u751f\u547d\u5468\u671f\uff0c\u4ee5\u53ca\u8fd0\u884c\u65f6\u57fa\u7840\u8bbe\u65bd\uff0cMultica \u4ece\u7b2c\u4e00\u5929\u8d77\u5c31\u662f\u56f4\u7ed5\u8fd9\u4e00\u7406\u5ff5\u6784\u5efa\u7684\u3002", + "\u548c\u5f53\u5e74\u7684 Multics \u4e00\u6837\uff0c\u8fd9\u4e00\u5224\u65ad\u5efa\u7acb\u5728\u201c\u591a\u8def\u590d\u7528\u201d\u4e4b\u4e0a\u3002\u4e00\u4e2a\u5c0f\u56e2\u961f\u4e0d\u8be5\u56e0\u4e3a\u4eba\u6570\u5c11\u5c31\u663e\u5f97\u80fd\u529b\u6709\u9650\u3002\u6709\u4e86\u5408\u9002\u7684\u7cfb\u7edf\uff0c\u4e24\u540d\u5de5\u7a0b\u5e08\u52a0\u4e0a\u4e00\u7ec4 agents\uff0c\u5c31\u80fd\u53d1\u6325\u51fa\u4e8c\u5341\u4eba\u56e2\u961f\u7684\u63a8\u8fdb\u901f\u5ea6\u3002", + "\u8fd9\u4e2a\u5e73\u53f0\u662f\u5b8c\u5168\u5f00\u6e90\u5e76\u652f\u6301\u81ea\u6258\u7ba1\u7684\u3002\u4f60\u7684\u6570\u636e\u59cb\u7ec8\u4fdd\u7559\u5728\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e2d\u3002\u4f60\u53ef\u4ee5\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6269\u5c55 API\uff0c\u63a5\u5165\u81ea\u5df1\u7684 LLM providers\uff0c\u4e5f\u53ef\u4ee5\u5411\u793e\u533a\u8d21\u732e\u4ee3\u7801\u3002", + ], + cta: "\u5728 GitHub \u4e0a\u67e5\u770b", + }, + + changelog: { + title: "\u66f4\u65b0\u65e5\u5fd7", + subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", + entries: [ + { + version: "0.1.3", + date: "2026-03-31", + title: "Agent \u667a\u80fd", + changes: [ + "\u901a\u8fc7\u8bc4\u8bba\u4e2d\u7684 @\u63d0\u53ca\u89e6\u53d1 Agent", + "\u5c06 Agent \u5b9e\u65f6\u8f93\u51fa\u63a8\u9001\u5230 Issue \u8be6\u60c5\u9875", + "\u5bcc\u6587\u672c\u7f16\u8f91\u5668\u2014\u2014\u63d0\u53ca\u3001\u94fe\u63a5\u7c98\u8d34\u3001\u8868\u60c5\u53cd\u5e94\u3001\u53ef\u6298\u53e0\u7ebf\u7a0b", + "\u6587\u4ef6\u4e0a\u4f20\uff0c\u652f\u6301 S3 + CloudFront \u7b7e\u540d URL \u548c\u9644\u4ef6\u8ddf\u8e2a", + "Agent \u9a71\u52a8\u7684\u4ee3\u7801\u4ed3\u5e93\u68c0\u51fa\uff0c\u5e26 bare clone \u7f13\u5b58\u7684\u4efb\u52a1\u9694\u79bb", + "Issue \u5217\u8868\u89c6\u56fe\u7684\u6279\u91cf\u64cd\u4f5c", + "\u5b88\u62a4\u8fdb\u7a0b\u8eab\u4efd\u8ba4\u8bc1\u548c\u5b89\u5168\u52a0\u56fa", + ], + }, + { + version: "0.1.2", + date: "2026-03-28", + title: "\u534f\u4f5c", + changes: [ + "\u90ae\u7bb1\u9a8c\u8bc1\u767b\u5f55\u548c\u57fa\u4e8e\u6d4f\u89c8\u5668\u7684 CLI \u8ba4\u8bc1", + "\u591a\u5de5\u4f5c\u533a\u5b88\u62a4\u8fdb\u7a0b\uff0c\u652f\u6301\u70ed\u91cd\u8f7d", + "\u8fd0\u884c\u65f6\u4eea\u8868\u677f\uff0c\u542b\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe", + "\u57fa\u4e8e\u8ba2\u9605\u8005\u7684\u901a\u77e5\u6a21\u578b\uff0c\u66ff\u4ee3\u786c\u7f16\u7801\u89e6\u53d1\u5668", + "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf\uff0c\u652f\u6301\u8bc4\u8bba\u7ebf\u7a0b\u56de\u590d", + "\u770b\u677f\u91cd\u65b0\u8bbe\u8ba1\uff0c\u652f\u6301\u62d6\u62fd\u6392\u5e8f\u3001\u7b5b\u9009\u548c\u663e\u793a\u8bbe\u7f6e", + "\u4eba\u7c7b\u53ef\u8bfb\u7684 Issue \u6807\u8bc6\u7b26\uff08\u5982 JIA-1\uff09", + "\u4ece ClawHub \u548c Skills.sh \u5bfc\u5165\u6280\u80fd", + ], + }, + { + version: "0.1.1", + date: "2026-03-25", + title: "\u6838\u5fc3\u5e73\u53f0", + changes: [ + "\u591a\u5de5\u4f5c\u533a\u5207\u6362\u548c\u521b\u5efa", + "Agent \u7ba1\u7406 UI\uff0c\u652f\u6301\u6280\u80fd\u3001\u5de5\u5177\u548c\u89e6\u53d1\u5668", + "\u7edf\u4e00\u7684 Agent SDK\uff0c\u652f\u6301 Claude Code \u548c Codex \u540e\u7aef", + "\u8bc4\u8bba CRUD\uff0c\u652f\u6301\u5b9e\u65f6 WebSocket \u66f4\u65b0", + "\u4efb\u52a1\u670d\u52a1\u5c42\u548c\u5b88\u62a4\u8fdb\u7a0b REST \u534f\u8bae", + "\u4e8b\u4ef6\u603b\u7ebf\uff0c\u652f\u6301\u5de5\u4f5c\u533a\u7ea7\u522b\u7684 WebSocket \u9694\u79bb", + "\u6536\u4ef6\u7bb1\u901a\u77e5\uff0c\u652f\u6301\u672a\u8bfb\u5fbd\u7ae0\u548c\u5f52\u6863", + "CLI \u652f\u6301 cobra \u5b50\u547d\u4ee4\uff0c\u7528\u4e8e\u5de5\u4f5c\u533a\u548c Issue \u7ba1\u7406", + ], + }, + { + version: "0.1.0", + date: "2026-03-22", + title: "\u57fa\u7840\u67b6\u6784", + changes: [ + "Go \u540e\u7aef\uff0c\u652f\u6301 REST API\u3001JWT \u8ba4\u8bc1\u548c\u5b9e\u65f6 WebSocket", + "Next.js \u524d\u7aef\uff0cLinear \u98ce\u683c UI", + "Issue \u652f\u6301\u770b\u677f\u548c\u5217\u8868\u89c6\u56fe\uff0c\u542b\u62d6\u62fd\u770b\u677f", + "Agent\u3001\u6536\u4ef6\u7bb1\u548c\u8bbe\u7f6e\u9875\u9762", + "\u4e00\u952e\u8bbe\u7f6e\u3001\u8fc1\u79fb CLI \u548c\u79cd\u5b50\u5de5\u5177", + "\u5168\u9762\u6d4b\u8bd5\u5957\u4ef6\u2014\u2014Go \u5355\u5143/\u96c6\u6210\u6d4b\u8bd5\u3001Vitest\u3001Playwright E2E", + ], + }, + ], + }, +}; diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index abb0d0c6..67ac07c3 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useRef } from "react"; -import { Bot, CalendarDays, ChevronRight, Maximize2, Minimize2, UserMinus, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { CalendarDays, Check, ChevronRight, Maximize2, Minimize2, UserMinus, X as XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@/shared/types"; @@ -24,14 +25,17 @@ 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"; import { useIssueStore } from "@/features/issues"; import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; import { api } from "@/shared/api"; +import { useFileUpload } from "@/shared/hooks/use-file-upload"; +import { FileUploadButton } from "@/components/common/file-upload-button"; +import { ActorAvatar } from "@/components/common/actor-avatar"; // --------------------------------------------------------------------------- // Pill trigger — shared rounded-full button style for toolbar @@ -62,10 +66,11 @@ function PillButton({ // --------------------------------------------------------------------------- export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record | null }) { + const router = useRouter(); const workspaceName = useWorkspaceStore((s) => s.workspace?.name); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorName, getActorInitials } = useActorName(); + const { getActorName } = useActorName(); const draft = useIssueDraftStore((s) => s.draft); const setDraft = useIssueDraftStore((s) => s.setDraft); @@ -88,6 +93,10 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? // Due date popover const [dueDateOpen, setDueDateOpen] = useState(false); + // File upload + const { uploadWithToast } = useFileUpload(); + const handleUpload = (file: File) => uploadWithToast(file); + const assigneeQuery = assigneeFilter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery)); const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery)); @@ -125,6 +134,30 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? useIssueStore.getState().addIssue(issue); clearDraft(); onClose(); + toast.custom((t) => ( +
+
+
+ +
+ Issue created +
+
+ + {issue.identifier} – {issue.title} +
+ +
+ ), { duration: 5000 }); } catch { toast.error("Failed to create issue"); } finally { @@ -175,7 +208,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? onClick={onClose} className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" > - + } /> @@ -186,19 +219,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} />
@@ -209,6 +236,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? defaultValue={draft.description} placeholder="Add description..." onUpdate={(md) => setDraft({ description: md })} + onUploadFile={handleUpload} debounceMs={500} />
@@ -264,14 +292,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? {assigneeType && assigneeId ? ( <> -
- {assigneeType === "agent" ? : getActorInitials(assigneeType, assigneeId)} -
+ {assigneeLabel} ) : ( @@ -318,9 +339,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} ))} @@ -341,9 +360,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" > -
- -
+ {a.name} ))} @@ -400,7 +417,11 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Footer */} -
+
+ descEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> 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..28323d41 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,29 @@ import type { NextConfig } from "next"; +import { config } from "dotenv"; +import { resolve } from "path"; + +// Load root .env so REMOTE_API_URL is available to next.config.ts +config({ path: resolve(__dirname, "../../.env") }); + +const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080"; 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..a3e75923 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,8 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@floating-ui/dom": "^1.7.6", + "@tiptap/extension-code-block-lowlight": "3.20.5", + "@tiptap/extension-image": "^3.20.5", "@tiptap/extension-link": "^3.20.5", "@tiptap/extension-mention": "^3.20.5", "@tiptap/extension-placeholder": "^3.20.5", @@ -35,6 +37,7 @@ "emoji-mart": "^5.6.0", "input-otp": "^1.4.2", "linkify-it": "^5.0.0", + "lowlight": "^3.3.0", "lucide-react": "catalog:", "next": "^16.1.6", "next-themes": "^0.4.6", diff --git a/apps/web/public/images/feature-bg-2.jpg b/apps/web/public/images/feature-bg-2.jpg new file mode 100644 index 00000000..c9c6d416 Binary files /dev/null and b/apps/web/public/images/feature-bg-2.jpg differ diff --git a/apps/web/public/images/feature-bg-3.jpg b/apps/web/public/images/feature-bg-3.jpg new file mode 100644 index 00000000..aed497fd Binary files /dev/null and b/apps/web/public/images/feature-bg-3.jpg differ diff --git a/apps/web/public/images/feature-bg-4.jpg b/apps/web/public/images/feature-bg-4.jpg new file mode 100644 index 00000000..16d7e96f Binary files /dev/null and b/apps/web/public/images/feature-bg-4.jpg differ diff --git a/apps/web/public/images/feature-bg.jpg b/apps/web/public/images/feature-bg.jpg new file mode 100644 index 00000000..4de72190 Binary files /dev/null and b/apps/web/public/images/feature-bg.jpg differ diff --git a/apps/web/public/images/landing-bg.jpg b/apps/web/public/images/landing-bg.jpg new file mode 100644 index 00000000..91437fa5 Binary files /dev/null and b/apps/web/public/images/landing-bg.jpg differ diff --git a/apps/web/public/images/landing-hero.png b/apps/web/public/images/landing-hero.png new file mode 100644 index 00000000..e9591314 Binary files /dev/null and b/apps/web/public/images/landing-hero.png differ diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index d7ab942e..fb309433 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); } @@ -202,13 +209,14 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/comments`); } - async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise { + async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise { return this.fetch(`/api/issues/${issueId}/comments`, { method: "POST", body: JSON.stringify({ content, type: type ?? "comment", ...(parentId ? { parent_id: parentId } : {}), + ...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}), }), }); } @@ -358,20 +366,6 @@ export class ApiClient { }); } - 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"); @@ -525,4 +519,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..476913fa --- /dev/null +++ b/apps/web/shared/hooks/use-file-upload.ts @@ -0,0 +1,84 @@ +"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 { + id: string; + 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 { id: att.id, 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..fe3b325d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,12 @@ importers: '@floating-ui/dom': specifier: ^1.7.6 version: 1.7.6 + '@tiptap/extension-code-block-lowlight': + specifier: 3.20.5 + version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0) + '@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) @@ -126,6 +132,9 @@ importers: linkify-it: specifier: ^5.0.0 version: 5.0.0 + lowlight: + specifier: ^3.3.0 + version: 3.3.0 lucide-react: specifier: 'catalog:' version: 1.0.1(react@19.2.3) @@ -1319,6 +1328,15 @@ packages: peerDependencies: '@tiptap/extension-list': ^3.20.5 + '@tiptap/extension-code-block-lowlight@3.20.5': + resolution: {integrity: sha512-EINMkflwiUfCkBTAj1meP+nwEEUyXKmJF4yQVHzbt/iIswMtIc/7qvyld92VBgXWJkc+vo/lIPioaZGoSO7TsQ==} + peerDependencies: + '@tiptap/core': ^3.20.5 + '@tiptap/extension-code-block': ^3.20.5 + '@tiptap/pm': ^3.20.5 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@3.20.5': resolution: {integrity: sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA==} peerDependencies: @@ -1368,6 +1386,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: @@ -2295,6 +2318,10 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.12.8: resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} engines: {node: '>=16.9.0'} @@ -2611,6 +2638,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -4908,6 +4938,14 @@ snapshots: dependencies: '@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5) + '@tiptap/extension-code-block-lowlight@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5) + '@tiptap/extension-code-block': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5) + '@tiptap/pm': 3.20.5 + highlight.js: 11.11.1 + lowlight: 3.3.0 + '@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)': dependencies: '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5) @@ -4949,6 +4987,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) @@ -5915,6 +5957,8 @@ snapshots: headers-polyfill@4.0.3: {} + highlight.js@11.11.1: {} + hono@4.12.8: {} html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): @@ -6159,6 +6203,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@11.2.7: {} lru-cache@5.1.1: diff --git a/server/cmd/multica/cmd_update.go b/server/cmd/multica/cmd_update.go new file mode 100644 index 00000000..1ed9169e --- /dev/null +++ b/server/cmd/multica/cmd_update.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update multica to the latest version", + RunE: runUpdate, +} + +// githubRelease is the subset of the GitHub releases API response we need. +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +func runUpdate(_ *cobra.Command, _ []string) error { + fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit) + + // Check latest version from GitHub. + latest, err := fetchLatestRelease() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not check latest version: %v\n", err) + } else { + latestVer := strings.TrimPrefix(latest.TagName, "v") + currentVer := strings.TrimPrefix(version, "v") + if currentVer == latestVer { + fmt.Fprintln(os.Stderr, "Already up to date.") + return nil + } + fmt.Fprintf(os.Stderr, "Latest version: %s\n\n", latest.TagName) + } + + // Detect installation method and update accordingly. + if isBrewInstall() { + return updateViaBrew() + } + + // Not installed via brew — show manual instructions. + fmt.Fprintln(os.Stderr, "multica was not installed via Homebrew.") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To install via Homebrew (recommended):") + fmt.Fprintln(os.Stderr, " brew install multica-ai/tap/multica") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Or download the latest release from:") + fmt.Fprintln(os.Stderr, " https://github.com/multica-ai/multica/releases/latest") + return nil +} + +// isBrewInstall checks whether the running multica binary was installed via Homebrew. +func isBrewInstall() bool { + exePath, err := os.Executable() + if err != nil { + return false + } + // Resolve symlinks (brew links binaries from Cellar into prefix/bin). + resolved, err := filepath.EvalSymlinks(exePath) + if err != nil { + resolved = exePath + } + + // Check if the resolved path is inside a Homebrew prefix. + // Common prefixes: /opt/homebrew (Apple Silicon), /usr/local (Intel Mac), or custom. + brewPrefix := getBrewPrefix() + if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) { + return true + } + + // Fallback: check well-known Homebrew paths. + for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} { + if strings.HasPrefix(resolved, prefix+"/Cellar/") { + return true + } + } + return false +} + +// getBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string. +func getBrewPrefix() string { + out, err := exec.Command("brew", "--prefix").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func updateViaBrew() error { + fmt.Fprintln(os.Stderr, "Updating via Homebrew...") + + cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("brew upgrade failed: %w\nYou can try manually: brew upgrade multica-ai/tap/multica", err) + } + + fmt.Fprintln(os.Stderr, "Update complete.") + return nil +} + +func fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index e8007c49..bf0abbfd 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -34,6 +34,7 @@ func init() { rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(updateCmd) } func main() { 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/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index 88529123..638ca813 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -21,8 +21,6 @@ func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID strin WorkspaceID: util.ParseUUID(testWorkspaceID), RecipientType: "member", RecipientID: util.ParseUUID(recipientID), - Limit: 100, - Offset: 0, }) if err != nil { t.Fatalf("ListInboxItems: %v", err) diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index b5e0e73b..a792b381 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,11 +83,9 @@ 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 (no user auth; daemon auth deferred to later) + // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { - 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)) r.Post("/register", h.DaemonRegister) r.Post("/deregister", h.DaemonDeregister) @@ -106,10 +108,12 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // 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) @@ -144,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)) @@ -171,9 +173,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/cmd/server/subscriber_listeners.go b/server/cmd/server/subscriber_listeners.go index 14261e94..42cda006 100644 --- a/server/cmd/server/subscriber_listeners.go +++ b/server/cmd/server/subscriber_listeners.go @@ -33,9 +33,16 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) { !(*issue.AssigneeType == issue.CreatorType && *issue.AssigneeID == issue.CreatorID) { addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee") } + + // Subscribe @mentioned users in description + if issue.Description != nil && *issue.Description != "" { + for _, m := range parseMentions(*issue.Description) { + addSubscriber(bus, queries, e.WorkspaceID, issue.ID, m.Type, m.ID, "mentioned") + } + } }) - // issue:updated — subscribe new assignee if assignee changed + // issue:updated — subscribe new assignee or @mentioned users bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) { payload, ok := e.Payload.(map[string]any) if !ok { @@ -45,13 +52,30 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) { if !ok { return } - assigneeChanged, _ := payload["assignee_changed"].(bool) - if !assigneeChanged { - return + + // Subscribe new assignee if assignee changed + if assigneeChanged, _ := payload["assignee_changed"].(bool); assigneeChanged { + if issue.AssigneeType != nil && issue.AssigneeID != nil { + addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee") + } } - if issue.AssigneeType != nil && issue.AssigneeID != nil { - addSubscriber(bus, queries, e.WorkspaceID, issue.ID, *issue.AssigneeType, *issue.AssigneeID, "assignee") + // Subscribe newly @mentioned users in description + if descriptionChanged, _ := payload["description_changed"].(bool); descriptionChanged && issue.Description != nil { + newMentions := parseMentions(*issue.Description) + if len(newMentions) > 0 { + prevMentioned := map[string]bool{} + if prevDescription, _ := payload["prev_description"].(*string); prevDescription != nil { + for _, m := range parseMentions(*prevDescription) { + prevMentioned[m.Type+":"+m.ID] = true + } + } + for _, m := range newMentions { + if !prevMentioned[m.Type+":"+m.ID] { + addSubscriber(bus, queries, e.WorkspaceID, issue.ID, m.Type, m.ID, "mentioned") + } + } + } } }) 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/auth/jwt.go b/server/internal/auth/jwt.go index 6ad212ff..f300ed70 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -37,6 +37,15 @@ func GeneratePATToken() (string, error) { return "mul_" + hex.EncodeToString(b), nil } +// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars. +func GenerateDaemonToken() (string, error) { + b := make([]byte, 20) // 20 bytes = 40 hex chars + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate daemon token: %w", err) + } + return "mdt_" + hex.EncodeToString(b), nil +} + // HashToken returns the hex-encoded SHA-256 hash of a token string. func HashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 58d5e999..c59d56e9 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -120,6 +120,9 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string { b.WriteString("**Trigger:** New Assignment\n\n") } + b.WriteString("## Quick Start\n\n") + fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID) + if len(ctx.AgentSkills) > 0 { b.WriteString("## Agent Skills\n\n") b.WriteString("The following skills are available to you:\n\n") diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index db3611f1..f163ef47 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -103,7 +103,7 @@ func TestPrepareDirectoryMode(t *testing.T) { if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -208,7 +208,6 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ "test-issue-id-1234", - "multica issue get", "## Agent Skills", "Go Conventions", } { 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..93cf8f3f 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,19 +71,22 @@ 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) } type CreateCommentRequest struct { - Content string `json:"content"` - Type string `json:"type"` - ParentID *string `json:"parent_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + AttachmentIDs []string `json:"attachment_ids"` } func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { @@ -133,7 +143,12 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment, nil) + // Link uploaded attachments to this comment. + if len(req.AttachmentIDs) > 0 { + h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs) + } + + 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 +160,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 +176,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 +306,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) @@ -255,11 +348,16 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { return } + // Collect attachment URLs before CASCADE delete removes them. + attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId)) + if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil { slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) writeError(w, http.StatusInternalServerError, "failed to delete comment") return } + + h.deleteS3Objects(r.Context(), attachmentURLs) slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...) h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{ "comment_id": commentId, diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index f65c9905..d0051766 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") @@ -471,12 +477,20 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") + task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID)) + if err != nil { + writeError(w, http.StatusNotFound, "task not found") + return + } + messages, err := h.Queries.ListTaskMessages(r.Context(), parseUUID(taskID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list task messages") return } + issueID := uuidToString(task.IssueID) + resp := make([]protocol.TaskMessagePayload, len(messages)) for i, m := range messages { var input map[string]any @@ -485,6 +499,7 @@ func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) { } resp[i] = protocol.TaskMessagePayload{ TaskID: taskID, + IssueID: issueID, Seq: int(m.Seq), Type: m.Type, Tool: m.Tool.String, diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go deleted file mode 100644 index 9cf7747f..00000000 --- a/server/internal/handler/daemon_pairing.go +++ /dev/null @@ -1,386 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -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"` -} - -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 - } - - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go new file mode 100644 index 00000000..a51711c0 --- /dev/null +++ b/server/internal/handler/file.go @@ -0,0 +1,338 @@ +package handler + +import ( + "context" + "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 + } + + h.deleteS3Object(r.Context(), att.Url) + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Attachment linking +// --------------------------------------------------------------------------- + +// linkAttachmentsByIDs links the given attachment IDs to a comment. +// Only updates attachments that belong to the same issue and have no comment_id yet. +func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) { + uuids := make([]pgtype.UUID, len(ids)) + for i, id := range ids { + uuids[i] = parseUUID(id) + } + if err := h.Queries.LinkAttachmentsToComment(ctx, db.LinkAttachmentsToCommentParams{ + CommentID: commentID, + IssueID: issueID, + Column3: uuids, + }); err != nil { + slog.Error("failed to link attachments to comment", "error", err) + } +} + +// deleteS3Object removes a single file from S3 by its CDN URL. +func (h *Handler) deleteS3Object(ctx context.Context, url string) { + if h.Storage == nil || url == "" { + return + } + h.Storage.Delete(ctx, h.Storage.KeyFromURL(url)) +} + +// deleteS3Objects removes multiple files from S3 by their CDN URLs. +func (h *Handler) deleteS3Objects(ctx context.Context, urls []string) { + if h.Storage == nil || len(urls) == 0 { + return + } + keys := make([]string, len(urls)) + for i, u := range urls { + keys[i] = h.Storage.KeyFromURL(u) + } + h.Storage.DeleteKeys(ctx, keys) +} 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/inbox.go b/server/internal/handler/inbox.go index e826172d..a1d5f0ef 100644 --- a/server/internal/handler/inbox.go +++ b/server/internal/handler/inbox.go @@ -5,7 +5,6 @@ import ( "encoding/json" "log/slog" "net/http" - "strconv" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" @@ -93,25 +92,10 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { } workspaceID := r.Header.Get("X-Workspace-ID") - limit := 50 - offset := 0 - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil { - limit = v - } - } - if o := r.URL.Query().Get("offset"); o != "" { - if v, err := strconv.Atoi(o); err == nil { - offset = v - } - } - items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{ WorkspaceID: parseUUID(workspaceID), RecipientType: "member", RecipientID: parseUUID(userID), - Limit: int32(limit), - Offset: int32(offset), }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list inbox") diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index bb24fcd7..0cf7dc17 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) @@ -524,12 +548,16 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { h.TaskService.CancelTasksForIssue(r.Context(), issue.ID) + // Collect all attachment URLs (issue-level + comment-level) before CASCADE delete. + attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID) + err := h.Queries.DeleteIssue(r.Context(), parseUUID(id)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete issue") return } + h.deleteS3Objects(r.Context(), attachmentURLs) userID := requestUserID(r) actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": 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/middleware/daemon_auth.go b/server/internal/middleware/daemon_auth.go new file mode 100644 index 00000000..d91282cf --- /dev/null +++ b/server/internal/middleware/daemon_auth.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/multica-ai/multica/server/internal/auth" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Daemon context keys. +type daemonContextKey int + +const ( + ctxKeyDaemonWorkspaceID daemonContextKey = iota + ctxKeyDaemonID +) + +// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware. +func DaemonWorkspaceIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string) + return id +} + +// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware. +func DaemonIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyDaemonID).(string) + return id +} + +// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to +// JWT/PAT validation for backward compatibility with daemons that +// authenticate via user tokens. +func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + slog.Debug("daemon_auth: invalid format", "path", r.URL.Path) + writeError(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + // Daemon token: "mdt_" prefix. + if strings.HasPrefix(tokenString, "mdt_") { + hash := auth.HashToken(tokenString) + dt, err := queries.GetDaemonTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid daemon token") + return + } + + ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID)) + ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Fallback: PAT tokens ("mul_" prefix). + if strings.HasPrefix(tokenString, "mul_") { + hash := auth.HashToken(tokenString) + pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + r.Header.Set("X-User-ID", uuidToString(pat.UserID)) + go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID) + next.ServeHTTP(w, r) + return + } + + // Fallback: JWT tokens. + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return auth.JWTSecret(), nil + }) + if err != nil || !token.Valid { + slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err) + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + sub, ok := claims["sub"].(string) + if !ok || strings.TrimSpace(sub) == "" { + writeError(w, http.StatusUnauthorized, "invalid claims") + return + } + r.Header.Set("X-User-ID", sub) + next.ServeHTTP(w, r) + }) + } +} diff --git a/server/internal/service/task.go b/server/internal/service/task.go index a05c1a9d..f46d0e14 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..b04a9561 --- /dev/null +++ b/server/internal/storage/s3.go @@ -0,0 +1,147 @@ +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() +} + +// KeyFromURL extracts the S3 object key from a CDN or bucket URL. +// e.g. "https://multica-static.copilothub.ai/abc123.png" → "abc123.png" +func (s *S3Storage) KeyFromURL(rawURL string) string { + // Strip the "https://domain/" prefix. + for _, prefix := range []string{ + "https://" + s.cdnDomain + "/", + "https://" + s.bucket + "/", + } { + if strings.HasPrefix(rawURL, prefix) { + return strings.TrimPrefix(rawURL, prefix) + } + } + // Fallback: take everything after the last "/". + if i := strings.LastIndex(rawURL, "/"); i >= 0 { + return rawURL[i+1:] + } + return rawURL +} + +// Delete removes an object from S3. Errors are logged but not fatal. +func (s *S3Storage) Delete(ctx context.Context, key string) { + if key == "" { + return + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + slog.Error("s3 DeleteObject failed", "key", key, "error", err) + } +} + +// DeleteKeys removes multiple objects from S3. Best-effort, errors are logged. +func (s *S3Storage) DeleteKeys(ctx context.Context, keys []string) { + for _, key := range keys { + s.Delete(ctx, key) + } +} + +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_daemon_token.down.sql b/server/migrations/029_daemon_token.down.sql new file mode 100644 index 00000000..18600acc --- /dev/null +++ b/server/migrations/029_daemon_token.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_token; diff --git a/server/migrations/029_daemon_token.up.sql b/server/migrations/029_daemon_token.up.sql new file mode 100644 index 00000000..6704aa08 --- /dev/null +++ b/server/migrations/029_daemon_token.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE daemon_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT NOT NULL, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + daemon_id TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash); +CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_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 fbe8f068..befca00e 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -489,6 +489,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..69d5f3de --- /dev/null +++ b/server/pkg/db/generated/attachment.sql.go @@ -0,0 +1,296 @@ +// 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 linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec +UPDATE attachment +SET comment_id = $1 +WHERE issue_id = $2 + AND comment_id IS NULL + AND id = ANY($3::uuid[]) +` + +type LinkAttachmentsToCommentParams struct { + CommentID pgtype.UUID `json:"comment_id"` + IssueID pgtype.UUID `json:"issue_id"` + Column3 []pgtype.UUID `json:"column_3"` +} + +func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachmentsToCommentParams) error { + _, err := q.db.Exec(ctx, linkAttachmentsToComment, arg.CommentID, arg.IssueID, arg.Column3) + return err +} + +const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many +SELECT url FROM attachment +WHERE comment_id = $1 +` + +func (q *Queries) ListAttachmentURLsByCommentID(ctx context.Context, commentID pgtype.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, listAttachmentURLsByCommentID, commentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + return nil, err + } + items = append(items, url) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAttachmentURLsByIssueOrComments = `-- name: ListAttachmentURLsByIssueOrComments :many +SELECT a.url FROM attachment a +WHERE a.issue_id = $1 + OR a.comment_id IN (SELECT c.id FROM comment c WHERE c.issue_id = $1) +` + +func (q *Queries) ListAttachmentURLsByIssueOrComments(ctx context.Context, issueID pgtype.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, listAttachmentURLsByIssueOrComments, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + return nil, err + } + items = append(items, url) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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/daemon_token.sql.go b/server/pkg/db/generated/daemon_token.sql.go new file mode 100644 index 00000000..367d7504 --- /dev/null +++ b/server/pkg/db/generated/daemon_token.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: daemon_token.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createDaemonToken = `-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at +` + +type CreateDaemonTokenParams struct { + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) { + row := q.db.QueryRow(ctx, createDaemonToken, + arg.TokenHash, + arg.WorkspaceID, + arg.DaemonID, + arg.ExpiresAt, + ) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2 +` + +type DeleteDaemonTokensByWorkspaceAndDaemonParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` +} + +func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error { + _, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID) + return err +} + +const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now() +` + +func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredDaemonTokens) + return err +} + +const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one +SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token +WHERE token_hash = $1 AND expires_at > now() +` + +func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) { + row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash) + var i DaemonToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.WorkspaceID, + &i.DaemonID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go index 375f66e8..b0c46a0d 100644 --- a/server/pkg/db/generated/inbox.sql.go +++ b/server/pkg/db/generated/inbox.sql.go @@ -239,15 +239,12 @@ FROM inbox_item i LEFT JOIN issue iss ON iss.id = i.issue_id WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false ORDER BY i.created_at DESC -LIMIT $4 OFFSET $5 ` type ListInboxItemsParams struct { WorkspaceID pgtype.UUID `json:"workspace_id"` RecipientType string `json:"recipient_type"` RecipientID pgtype.UUID `json:"recipient_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` } type ListInboxItemsRow struct { @@ -270,13 +267,7 @@ type ListInboxItemsRow struct { } func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]ListInboxItemsRow, error) { - rows, err := q.db.Query(ctx, listInboxItems, - arg.WorkspaceID, - arg.RecipientType, - arg.RecipientID, - arg.Limit, - arg.Offset, - ) + rows, err := q.db.Query(ctx, listInboxItems, arg.WorkspaceID, arg.RecipientType, arg.RecipientID) if err != nil { return nil, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..7c812e50 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"` @@ -113,22 +127,13 @@ type DaemonConnection struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type DaemonPairingSession struct { - ID pgtype.UUID `json:"id"` - 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 pgtype.UUID `json:"workspace_id"` - ApprovedBy pgtype.UUID `json:"approved_by"` - Status string `json:"status"` - ApprovedAt pgtype.Timestamptz `json:"approved_at"` - ClaimedAt pgtype.Timestamptz `json:"claimed_at"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` +type DaemonToken struct { + ID pgtype.UUID `json:"id"` + TokenHash string `json:"token_hash"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + DaemonID string `json:"daemon_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type InboxItem struct { diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 3529c4f4..4511200b 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -130,6 +130,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..fc5a710d --- /dev/null +++ b/server/pkg/db/queries/attachment.sql @@ -0,0 +1,42 @@ +-- 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: ListAttachmentURLsByIssueOrComments :many +SELECT a.url FROM attachment a +WHERE a.issue_id = $1 + OR a.comment_id IN (SELECT c.id FROM comment c WHERE c.issue_id = $1); + +-- name: ListAttachmentURLsByCommentID :many +SELECT url FROM attachment +WHERE comment_id = $1; + +-- name: LinkAttachmentsToComment :exec +UPDATE attachment +SET comment_id = $1 +WHERE issue_id = $2 + AND comment_id IS NULL + AND id = ANY($3::uuid[]); + +-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2; diff --git a/server/pkg/db/queries/daemon_token.sql b/server/pkg/db/queries/daemon_token.sql new file mode 100644 index 00000000..252b17f2 --- /dev/null +++ b/server/pkg/db/queries/daemon_token.sql @@ -0,0 +1,16 @@ +-- name: CreateDaemonToken :one +INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetDaemonTokenByHash :one +SELECT * FROM daemon_token +WHERE token_hash = $1 AND expires_at > now(); + +-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec +DELETE FROM daemon_token +WHERE workspace_id = $1 AND daemon_id = $2; + +-- name: DeleteExpiredDaemonTokens :exec +DELETE FROM daemon_token +WHERE expires_at <= now(); diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql index be0e9310..301d93e3 100644 --- a/server/pkg/db/queries/inbox.sql +++ b/server/pkg/db/queries/inbox.sql @@ -4,8 +4,7 @@ SELECT i.*, FROM inbox_item i LEFT JOIN issue iss ON iss.id = i.issue_id WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false -ORDER BY i.created_at DESC -LIMIT $4 OFFSET $5; +ORDER BY i.created_at DESC; -- name: GetInboxItem :one SELECT * FROM inbox_item