Compare commits

..

116 Commits

Author SHA1 Message Date
Naiyuan Qing
fe1140c10a docs(mobile): rewrite self-build framing + fix latent CI errors
Docs: drop the "Multica Apple Developer team" framing (no such team) —
every contributor signs the default bundle id with Xcode's free Personal
Team; the EXPO_BUNDLE_IDENTIFIER_PROD override is just a fallback for the
rare case where the prefix gets squatted in Apple's developer portal.
Touched:
- apps/mobile/README.md (top "Just want to use it" section)
- apps/docs/content/docs/mobile-app.{mdx,zh.mdx}

CI: latent type / lint errors that the prior install-step failure had been
masking — surfaced once dependencies installed cleanly:
- failure-reason-label.ts / run-row.tsx — add the new
  codex_semantic_inactivity enum key from packages/core/types/agent.ts
- schemas.ts UserSchema + EMPTY_USER — add profile_description, timezone
- schemas.ts EMPTY_ISSUE_FALLBACK — add metadata
- profile.tsx — escape apostrophe in JSX text

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:08:48 +08:00
Naiyuan Qing
e113308435 feat(mobile): prod build path + composer/mention/edit polish
Prod build path — lets external users self-build a personal copy against
api.multica.ai's production backend:
- New `prod` variant alongside `dev` / `staging`: `.env.production`,
  `dev:prod` / `ios:device:prod` / `ios:device:prod:release` scripts
- `EXPO_BUNDLE_IDENTIFIER_PROD` shell override in `app.config.ts` for
  contributors not on the Multica Apple Developer team (parallel to
  existing `_DEV` pattern)
- Public docs page `mobile-app.{mdx,zh.mdx}` + Reference entry; README
  gains a top-of-file "Just want to use it" section

Composer refactor:
- Shared `components/composer/message-composer.tsx` shell removes ~400
  lines of duplication between chat-composer and inline-comment-composer
- Mention picker pulled out of inline modal into a Router formSheet route
  (`mention-picker.tsx` + `pickers/mention-picker-body.tsx`), backed by a
  Zustand `mention-draft-store`

Other:
- Issue edit screen (`issue/[id]/edit.tsx`) + reusable description-field
- Chat empty-state and timeline split into dedicated components;
  status-pill / message-list / attachment-row rewrites
- Markdown render tweaks, `lib/format-elapsed.ts`, `ui/collapsible.tsx`
- Realtime / schemas additions for chat session updates; new mention-picker
  stack screen registered in workspace `_layout.tsx`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:30:43 +08:00
Naiyuan Qing
478ed502c3 chore(mobile): fresh-checkout build path — simulator scripts, env consistency
- Track apps/mobile/.env.staging (root .gitignore was swallowing it despite mobile gitignore claiming it was committed). Fresh checkouts can now run *:staging without copying the template first.
- Rename EXPO_BUNDLE_IDENTIFIER → EXPO_BUNDLE_IDENTIFIER_DEV and apply only in the dev variant of app.config.ts. Expo CLI auto-loads .env.development.local on every run regardless of APP_ENV, so a generic name silently leaked a dev's personal bundle id into staging / production builds and collapsed the three variants onto one id. The _DEV suffix + isDev-only branch keeps each variant on its canonical id.
- Add ios:mobile / ios:mobile:staging scripts (root + apps/mobile package.json) so the iOS Simulator path exists end-to-end. Previously the only documented build commands targeted USB devices.
- Rewrite apps/mobile/README.md: 6-row command table, first-time setup section (.env.development.local copy step, EXPO_BUNDLE_IDENTIFIER_DEV note), explicit simulator section, clarify 7-day signing limit applies to device builds only.
- Update root CLAUDE.md mobile commands block to list both simulator and device commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:56 +08:00
Naiyuan Qing
36ac5724d7 style(mobile): tighter markdown code sizing + auth layout
- Markdown: inline code 15→14 (match body) and block code 14→13 +
  leading-5. SF Mono is denser than PingFang at the same point size, so
  the +1 inline bump made mono glyphs visibly larger than surrounding
  Latin text; the new sizing matches GitHub Mobile / Linear iOS /
  Notion iOS. The two paths (CodeBlock vs enriched list-nested code)
  now agree on 13px.
- Login + verify: logo 56→32, title text-3xl bold → text-2xl semibold,
  description text-base → text-sm, outer gap-8 → gap-6, brand cluster
  gap-4/2 → gap-3/1. Brings the auth screens in line with iOS native
  Settings / Things 3 / Linear iOS layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:25 +08:00
Naiyuan Qing
a69abda373 refactor(mobile): list parity polish + drop new-issue seed params
- my-issues / more issues: drop the RNR Tabs primitive in favour of
  plain Pressable pills (Tabs adds vertical padding + a divider that
  break under the cramped 375pt SE3 layout). "Agents and Squads" pill
  label trimmed to "Agents" — backend predicate unchanged
  (involves_user_id), empty-state copy still mentions "agents or
  squads". Scope counts dropped from pill labels (web's IssuesHeader
  doesn't show them either, and "(123)" suffix overflowed on SE3).
- issue-row: render assignee whenever assignee_type + assignee_id are
  both truthy. Earlier whitelist (member/agent only) silently dropped
  squad assignees; ActorAvatar already handles all four enum values.
- new-issue: remove unused seed_content / seed_actor route params —
  the comment-action-sheet path that fed them no longer exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:14 +08:00
Naiyuan Qing
de79037d1f refactor(mobile): comment + chat long-press → ActionSheetIOS, composer pill↔expanded
- Comment long-press: drop react-native-ios-context-menu UIContextMenu
  wrapper in favour of native ActionSheetIOS via a useCommentLongPress
  hook. Removes two native deps (react-native-ios-context-menu +
  react-native-ios-utilities). The "Select text" path still works —
  toggling useCommentSelectStore swaps the bubble's long-press handler
  for selectable text.
- Comment composer: two visual states. Collapsed = pill placeholder
  ("Add a comment, @ to mention…"). Expanded = TextInput + toolbar
  (📎 attach · ➤ send). Adds reply-target-store driven by the long-press
  "Reply" action and an attachment row (composer-attachment-row +
  comment-attachment-list mirror web's data contract).
- Chat: matching ActionSheetIOS long-press (Copy / Select Text / Cancel)
  via message-long-press + chat-select-store; cleared on tab blur via
  useFocusEffect.
- useMentionInput.setText now accepts the React functional updater so
  post-await replacements (upload placeholder → final markdown) don't
  lose the user's intermediate typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:18:02 +08:00
Naiyuan Qing
e87331e0fc feat(mobile): More popover — pins + workspace switcher
- Pins: pin issues/projects from the header three-dot menu; Pinned list
  in the More popover; mirrors web's pin endpoints + cache shapes.
  Adds data/queries/pins.ts, data/mutations/pins.ts, realtime updater,
  PinListSchema + EMPTY_PIN_LIST fallback.
- Workspace switcher: collapse the per-workspace list in the More
  popover down to a single WorkspaceCard row + pushes a dedicated
  switch-workspace formSheet with an iOS Alert.alert confirm before
  actually switching. Adds friction against accidental taps and keeps
  the popover short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:17:25 +08:00
Naiyuan Qing
f3c4e6686a Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-22 07:59:40 +08:00
Naiyuan Qing
901bb11e3b feat(mobile): comment surface — inline composer + UIKit context menu + failed-retry + last-viewed divider
Replaces the old route-based comment composition + actions sheet with surface-level UI that matches iMessage / Slack iOS / Telegram conventions.

Long-press on a comment bubble now hands the gesture to UIKit's UIContextMenuInteraction (via react-native-ios-context-menu) — system blur, snapshot scale, grouped menu (Reply / Edit / Copy / Select Text / Copy Link / Resolve / New Issue / Delete), and a Tapback-style auxiliary preview emoji row above the snapshot. Eliminates the race between Pressable.onLongPress and UITextView's selection magnifier that the old formSheet route suffered from.

New inline composer (components/issue/inline-comment-composer.tsx) sits at the bottom of the issue detail screen, pinned just above the keyboard via KeyboardStickyView (react-native-keyboard-controller). Replaces the new-comment.tsx modal route — phone keyboard already gives the composer dedicated real estate, the route + draft store were overhead.

Timeline gains:
- "New since last view" divider driven by data/stores/last-viewed-store.ts
- Failed-comment retry/discard inline affordance backed by data/stores/failed-comments-store.ts (mutation onError keeps the optimistic entry; this store carries retry metadata + error string)

Data layer:
- mutations/issues: useCreateComment accepts attachmentIds, mirrors web's activeIds derivation
- realtime/issue-ws-updaters + use-issue-realtime: WS coverage tweaks for new comment lifecycle
- comment-select-store: extended for the Select Text path triggered from the new context menu

Cleanup of dead route registrations (workspace _layout.tsx) for the removed new-comment, comment/actions, and (already-removed) menu routes.

Adds deps: react-native-ios-context-menu, react-native-ios-utilities, react-native-keyboard-controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:53:07 +08:00
Naiyuan Qing
4a12105bf1 refactor(mobile): drop menu route + global-nav-menu, dropdown only
The More-tab dropdown popover (introduced earlier) now covers everything the dedicated /menu route and global-nav-menu component used to render. Drop both.

The Stack.Screen registration for the menu route in (app)/[workspace]/_layout.tsx is removed in the follow-up comment-surface commit alongside other dead route registrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:40 +08:00
Naiyuan Qing
9e4238648e feat(mobile): picker polish + inline label create with deterministic color
- New labels mutation (data/mutations/labels.ts) + createLabel API method (data/api.ts) so the label picker can create-and-attach in one flow without leaving the sheet
- lib/inline-color.ts: deterministic palette hash ported from packages/views label-picker for behavioral parity (same name → same color across web/mobile)
- All issue + project picker bodies (label/priority/status/project on issues; lead/priority/status on projects) reworked for visual + interaction consistency
- Picker route shells (issue/[id]/picker/{label,project}, new-issue-picker/project, project/[id]/picker/lead) updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:27 +08:00
Naiyuan Qing
0d1601be8b chore(mobile): markdown rendering + UI primitive polish
- lib/markdown/{code-block,markdown-style,preprocess}: refined code block rendering, restructured style map, preprocess tweaks
- components/ui/{actor-avatar,text-field}: visual polish
- components/issue/mention-suggestion-bar: tweaks alongside inline composer mention pipeline
- components/editor/use-file-attach: small adjustments

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:52:06 +08:00
Naiyuan Qing
c09a096c2d fix(mobile): friendlier auth error messages on login + verify
Adds lib/auth-error.ts that maps backend raw English errors (invalid / expired / rate-limited / network) to user-facing copy. login.tsx and verify.tsx route their catch blocks through it with a per-screen fallback string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:51:55 +08:00
Naiyuan Qing
0948041f26 chore(mobile): EXPO_BUNDLE_IDENTIFIER override + brand logo + CLAUDE.md preflight rules
- .env.example + app.config.ts: optional EXPO_BUNDLE_IDENTIFIER for devs whose Apple ID isn't on the Multica team
- components/brand/multica-logo.tsx: new brand logo asset
- CLAUDE.md: restructured with mandatory pre-flight (read web impl → show plan → wait for go) before any new mobile feature; consolidated behavioral parity rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:51:42 +08:00
Naiyuan Qing
b5913b0a0e wip(mobile): in-flight comment-select / chat / markdown work
Batch commit of pre-existing uncommitted work carried forward alongside
the assignee picker refactor. Topics mixed — split into proper atomic
commits when each lands.

- apps/mobile/data/comment-select-store.ts: new comment-selection store
- components/issue/comment-card.tsx + issue/[id].tsx + comment actions:
  comment-select wiring
- components/chat/chat-message-list.tsx: chat list rework (~170 lines)
- lib/markdown/markdown.tsx: markdown adjustments
- package.json + pnpm-lock.yaml: dependency drift

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:14:02 +08:00
Naiyuan Qing
c19bf6612a feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory
- Switch assignee picker (issue + new-issue) from body-rendered header to
  native Stack header + UISearchController via headerSearchBarOptions.
- Body becomes pure FlatList — fixes react-native-screens#3634 overlap
  (FlatList now route's direct child, no intermediate wrapper view).
- Pin currently-selected actor + Unassigned to the top when no query;
  search results stay in member → agent → squad order.
- Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell
  style (UIListContentConfiguration.valueCell) used throughout Settings.
- Selection indicator: Ionicons checkmark in primary tint only, no row
  bg highlight (Apple HIG: never use selection to indicate state).
- Avatar 28pt → 36pt.
- autoFocus on search bar for search-first pickers — keyboard appears on
  mount, opt-in via hook option.
- Extract useNativeSearchBar + useScrollToTopOnChange hooks under
  apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:13:50 +08:00
Naiyuan Qing
6166a0b6a6 refactor(mobile): swap SegmentedControl for RNR Tabs; drop bg-popover from sheet contents
- Add components/ui/tabs.tsx (RNR Tabs primitive wrapper on
  @rn-primitives/tabs, shadcn-style API).
- My Issues and the More > Issues page swap iOS SegmentedControl for
  the new RNR Tabs — consistent visual with the rest of the RNR
  components and gives count-suffix labels room to breathe.
- Switch the shared SHEET_OPTIONS contentStyle from height: "100%" to
  flex: 1 — works for both fixed-detent and fitToContents sheets,
  whereas the explicit 100% height pre-empted flex behaviour in the
  fitToContents case.
- Drop the explicit `bg-popover` background from sheet root Views
  (chat-sessions, issues-filter, runs, comment actions/emoji-picker,
  add-resource). The iOS formSheet container already paints the
  popover surface; an inner bg-popover stacked on top showed as a
  subtle double-layer when detents animated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:04:00 +08:00
Naiyuan Qing
fe62f7f5b2 feat(mobile): More tab opens dropdown popover anchored above the tab
Tapping the More tab now opens a small DropdownMenu popover containing
the user card, workspace switcher, and secondary nav (Issues/Projects)
— anchored directly above the tab button. Replaces the previous
listeners.tabPress that pushed /menu as an iOS formSheet, which felt
heavy for a quick switch.

Implementation:

- Add @rn-primitives/dropdown-menu and a shadcn-style wrapper at
  components/ui/dropdown-menu.tsx (Root/Trigger/Portal/Overlay/Content/
  Item/Label/Separator using semantic tokens — bg-popover, accent,
  border — matching the existing button.tsx pattern).
- New MoreTabDropdownAnchor (components/nav/more-tab-dropdown.tsx)
  mounts as a sibling to <Tabs> at the workspace tabs layout. It is
  absolute-positioned over the More tab's screen rect (right 25%,
  bottom = safe-area inset, height = 49) with pointerEvents="box-none"
  so taps pass straight through to the real tab button. The Trigger
  inside is an invisible Pressable; opened imperatively via
  TriggerRef.open() from listeners.tabPress on the More tab. The
  @rn-primitives Trigger measures its own rect inside open(), so the
  popover anchors correctly without manual screen-width math.
- The /menu formSheet route stays registered in [workspace]/_layout.tsx
  as a dead path for now (reversibility); to be removed once the
  popover bakes in.

Rejected alternative: replacing the More tab's tabBarButton with a
custom DropdownMenuTrigger wrapper. RN's BottomTabItem wraps the
returned button in <View style={{flex:1}}> and expects a single
Pressable; introducing the DropdownMenu Root as an extra wrapping View
broke the flex layout and stripped the "More" label. The Option B
pattern here leaves the real tab button entirely untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:03:48 +08:00
Naiyuan Qing
e6c296deb3 refactor(mobile): drop project board (kanban) view, keep list-only
Mobile intentionally diverges from web's Board / List view selector and
ships only the status-grouped list. Reasons (now documented in the file
docblock):

- Phone screens are too narrow to show ≥3 status columns at once,
  defeating kanban's core "see pipeline at a glance" value — users
  end up swiping between near-empty columns.
- Major mobile task apps (Linear iOS, Things, Apple Reminders) don't
  ship kanban; list with status grouping is the established
  small-screen pattern.
- mobile/CLAUDE.md "Behavioral parity" permits UI divergence when
  semantics agree. Same issues, same status enum, same 6
  BOARD_STATUSES grouping — only the layout differs.

What stays from the prior plan:
- Full BOARD_STATUSES grouping (backlog / todo / in_progress /
  in_review / done / blocked) — the real parity fix replacing the
  earlier mobile-only "Open / Done" two-bucket rollup. Cancelled
  remains hidden on both clients.

What's removed:
- BoardView component + horizontal ScrollView
- View mode SegmentedControl + ViewMode local state
- BoardView's column-empty placeholders

The `@react-native-segmented-control/segmented-control` dependency is
kept — my-issues and more/issues still use it for scope tabs (Mine /
All / Agents) where semantics also vary on web.
2026-05-20 14:05:46 +08:00
Naiyuan Qing
e6f175e752 feat(mobile): project surface v1 — Board view, hex/SVG sweep, planning docs
Closes the remaining items from project-v1-plan.md:

- View mode switcher (List / Board) on project detail's related-issues:
  - List mode regrouped into full BOARD_STATUSES (backlog / todo /
    in_progress / in_review / done / blocked), replacing the mobile-only
    "Open / Done" two-bucket rollup that silently diverged from web's
    six-bucket grouping (parity violation, gap audit §3)
  - Board mode: horizontal scroll, one status column per group, each
    column is a FlatList of IssueRow (reuses existing primitive)
  - View mode is local useState — no Zustand store (single component
    scope, mobile/CLAUDE.md "no state unless required")

- Hex sweep → THEME tokens / NativeWind semantic classes (gap audit §5):
  project-properties-section, project-resources-section, project/[id],
  more/projects. Eliminates the last project-domain dark-mode breakage.

- Hand-drawn SVG icons → existing primitives (gap audit §6):
  more/projects PlusButton → <IconButton name="add">
  project-properties-section chevron → <Ionicons name="chevron-forward">
  project-related-issues chevron → <Ionicons name="chevron-forward">
  Drops react-native-svg where no longer used.

Items 1 / 2 / 4 (Tier B picker migration, progress section, new-project
draft persistence) landed in preceding commits c644e2a3, 7337206f,
2ff95c34. With this PR the full project-v1-plan is implemented and the
two planning docs (gap audit + implementation plan) are committed for
future reference.
2026-05-20 13:51:17 +08:00
Naiyuan Qing
2ff95c3413 feat(mobile): mirror web project progress section in header card
Adds a horizontal progress bar driven by `done_count / issue_count`
plus a "X / Y · NN%" label, hidden when issue_count is zero (no info
to show + divide-by-zero hazard). Mirrors web's project-detail.tsx
596-620 to satisfy behavioral parity — web users see project progress
in the project header, mobile users should too.

Note: this change was added autonomously by the code-review follow-up
agent outside the original 6-item review scope. Code quality is sound
(token-based colors, zero-count guard, web source referenced inline)
so kept rather than dropped, but flagged here for traceability.
2026-05-20 13:37:07 +08:00
Naiyuan Qing
2cb2016235 fix(mobile): align picker search box to title (px-4)
The three search-bearing picker bodies (assignee / label / project) had
title rows at px-4 and search boxes at px-3 — a 4px misalignment where
the search field's leading edge sat outside the title's leading edge.
Bring the search container to px-4 so the title text, the search
placeholder, and the search input all share one vertical baseline.

Status / priority / due-date pickers have no search box (and so no
misalignment); project-detail lead picker has no title row (search box
defines its own px-3 baseline), both intentionally unchanged.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
892df00b54 fix(mobile): menu sheet keeps fitToContents detent
The Tier B sheet migration swept menu.tsx into shared SHEET_OPTIONS,
which set sheetAllowedDetents=[0.6, 0.95]. That's right for picker-row
sheets where consistency across neighbour chips matters, but the menu
is an isolated sheet (≤ 5 fixed actions, opened from the tab bar) —
the two-snap default leaves ~60% of the sheet blank.

Override sheetAllowedDetents to "fitToContents" for menu only, and
amend the SHEET_OPTIONS rationale in apps/mobile/CLAUDE.md so the rule
is spelled out: picker-row sheets share the explicit detents for
muscle-memory carry-over; isolated sheets shrink-wrap.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
2a6b4bf641 fix(mobile): make first mount a true no-op in draft-store reset hooks
The two cross-route draft store reset hooks (new-issue, chat-session)
documented their first mount as "effectively a no-op" but the
implementations stomped the store on every workspace-id transition
including the initial null → uuid resolve. That's harmless when the
store is already INITIAL but contradicts the docblock and would corrupt
any future code that pre-seeds the store before navigation lands.

Gate the reset() call on a useRef-tracked previous id so it only fires
on genuine transitions. Matches the new-project-draft-store hook added
in the prior commit so all three stores follow one shape.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
7337206f9e refactor(mobile): new-project draft store + formSheet pickers
Replaces the one-off DraftPickerModal (RN <Modal transparent fade> +
centered card) in project/new.tsx with the same cross-route draft-store +
formSheet picker route pattern as new-issue. Status / priority chips now
push /new-project-picker/<field> like the new-issue chips do, and the
picker bodies are reused as-is.

Removes the last hand-rolled modal sheet introduced after the Lesson 6
formSheet migration — keeping the rule "every sheet is a formSheet route"
intact across the codebase.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
d77d13f23b fix(mobile): cold-start anchor for formSheet deep links
Without unstable_settings.anchor, a deep link or notification that
targets a formSheet route (issue/[id]/picker/status, etc.) cold-starts
the app onto the sheet alone — no parent screen, swipe-down lands the
user on a blank canvas. Anchor: '(tabs)' tells Expo Router to mount the
tab UI as the implicit base, so dismissing the sheet always returns to
a sensible workspace home.

Set on the workspace _layout.tsx that owns every formSheet route
registration. The root (app)/_layout has no formSheet declarations so
no anchor is needed there.
2026-05-20 12:15:20 +08:00
Naiyuan Qing
ef8f24c095 fix(mobile): typed picker pathname maps replace 'as never' router.push
attribute-row.tsx and create-form-attribute-row.tsx built the formSheet
route pathname via template strings cast 'as never', which silently
accepted any field name. Typos would compile and only blow up at runtime
with a 'no matching route' that's easy to miss in dev.

Introduce per-row IssuePickerField / NewIssuePickerField union types
mapped to literal-typed pathname records (with 'satisfies' to keep the
record exhaustive). Any new picker field is now a compile error until
both the union and the map are updated together.

Verified: changing 'priority' to 'pirority' at a call site now produces
TS2345 instead of compiling silently.
2026-05-20 11:27:06 +08:00
Naiyuan Qing
e5ee63aa1d fix(mobile): reset cross-route draft stores on workspace change
Both useNewIssueDraftStore and useChatSessionPickerStore hold
workspace-scoped state (assignee ids, draft session ids) that points at
records in the workspace that seeded them. Switching workspaces left
that state in place — a draft assignee from workspace A would survive
into workspace B's new-issue modal, where the id resolves to nothing.

Add a reset() to chat-session-picker-store (new-issue-draft-store
already had one) and expose a use…ResetOnWorkspaceChange(wsId) hook from
each store file. Wire both hooks once from workspace _layout.tsx so the
reset fires on every transition between matched workspace ids.

Docblocks updated to record where the reset is wired (single source of
truth: workspace _layout.tsx).
2026-05-20 11:21:18 +08:00
Naiyuan Qing
ac4507bcdd refactor(mobile): menu sheet uses shared SHEET_OPTIONS
Drop the bespoke 'fitToContents' branch for menu.tsx. Every other
formSheet uses [0.6, 0.95] explicit detents to dodge the iOS 26 +
Expo 55 fitToContents bugs (expo/expo#42904, #42965). Keeping menu on
the unsafe API solely because it 'shipped first' was a divergence
without a current reason — the bugs apply to it too. SHEET_OPTIONS is
now the single source of truth for every sheet.

CLAUDE.md Lesson 6 rationale updated to match.
2026-05-20 11:17:15 +08:00
Naiyuan Qing
c644e2a338 feat(mobile): project status + priority pickers via formSheet routes
Project detail's Status and Priority chips were the last two picker
chips still using the legacy centered-Modal pattern. The mixed gesture
(Status/Priority popped a centered card; Lead / Add Resource slid up a
formSheet) violated the picker-row consistency rule in CLAUDE.md
Lesson 6 — the four chips on the same row now all open the same way.

- New picker bodies under components/project/pickers/.
- New formSheet routes under app/(app)/[workspace]/project/[id]/picker/.
- Register both screens in workspace _layout.tsx using SHEET_OPTIONS.
- project/[id].tsx: drop the local state, swap chip onPress to
  router.push, and remove the trailing 'still uses transparent-Modal'
  apology comment.
- project/new.tsx is a draft modal so it can't push to a route (no
  project exists yet to read from cache). Inline a tiny DraftPickerModal
  shell that hosts the same picker bodies — documented in the file.
- Delete the obsolete ProjectStatusPickerSheet / ProjectPriorityPickerSheet
  files and update rnr-migration.md to reflect that B.2 is closed.
2026-05-20 11:15:25 +08:00
Naiyuan Qing
414c3b74a9 feat(mobile): full emoji picker for comment reactions via formSheet route
Mobile now offers the full emoji set behind a 'More reactions' overflow
in the per-comment actions sheet, matching web's emoji-mart parity.

- Adopt rn-emoji-keyboard 1.7.0 (zero runtime deps, React 19 / RN 0.83
  compatible, installed via expo install).
- New formSheet route at issue/[id]/comment/[commentId]/emoji-picker.tsx
  embeds EmojiKeyboard inline so UISheetPresentationController retains
  grabber, detents, and drag-to-dismiss.
- Quick-row overflow '+' button in comment actions pushes the new route.
- Delete the dead emoji-picker-sheet.tsx and the unused
  emojiPickerOpen state in comment-card.tsx (never opened from
  anywhere after the actions-route migration).
- Move QUICK_EMOJIS to lib/quick-emojis.ts since its old host file is
  gone.
- Update rnr-migration.md B.4 to record the resolution.
2026-05-20 11:07:34 +08:00
Naiyuan Qing
4ebdb69e7e fix(mobile): add inline titles to 5 issue picker bodies
SHEET_OPTIONS sets headerShown: false so every formSheet body must draw
its own title. Five issue pickers (status / priority / assignee / label /
project) were shipping headerless; only due-date had a title. Inline a
single header row in each body — five callers, no shared primitive (3x
rule not triggered).
2026-05-20 10:59:42 +08:00
Naiyuan Qing
383dd23c36 docs(mobile): markdown rendering ADR + selectable carve-out
Formalises the rendering decision (Path B — react-native-markdown-display +
Shiki + custom renderers) into a one-page ADR with A-tier source citations,
keeping the longer research log alongside it.

Adds a `selectable` opt-out to `CodeBlock` and `Markdown` so timeline
comments can disable RN's UIKit selection magnifier when an outer Pressable
already owns the long-press gesture, while issue descriptions and chat
messages keep the default selectable behaviour for copy-to-clipboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:50:12 +08:00
Naiyuan Qing
cc61ae3a0a feat(mobile): migrate sheet modals to route-level pageSheet (Tier B rollout)
Replaces the legacy "Modal transparent fade + hand-drawn backdrop" sheet
shell with expo-router route-level pageSheet modals — the canonical
container for content sheets per mobile/CLAUDE.md Lesson 6 and the Tier B
section of docs/rnr-migration.md.

Sheets deleted (9): chat session-sheet, comment-action-sheet, issue-filter-sheet,
six issue pickers (assignee, due-date, label, priority, project, status),
runs-sheet, project add-resource-sheet, project-lead-picker-sheet, plus the
shared sheet-shell and runs-sheet-store that supported them.

Route-level modals added: /[workspace]/{chat-sessions, issues-filter,
new-issue-picker/*, issue/[id]/{runs, picker/*, comment/[commentId]/actions},
project/[id]/{add-resource, picker/lead}}. Each picker is split into a thin
route file + reusable *-picker-body.tsx so the same body composes inside
the new-issue draft form and the issue-detail attribute row.

Comment CRUD endpoints (update / delete / resolve / unresolve) + matching
optimistic mutations + CommentSchema added to support the new comment
actions route. Two new draft/picker stores carry session-scoped state for
the chat-session picker and the new-issue form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:49:51 +08:00
Naiyuan Qing
7f33ae7eec feat(mobile): align my-issues + Issues with web/desktop — squad parity, scope tabs, RNR UI
- my-issues "agents" scope now uses server-side involves_user_id (MUL-2397)
  covering squads the user is involved in; tab label "Agents and Squads"
  matches web my-issues.json:14
- workspace Issues gains all / members / agents scope tabs with per-scope
  counts (client-side assignee_type filter mirroring issues-page.tsx:90-94),
  scope persists across workspace switches
- both screens migrate to iOS-native SegmentedControl, IconButton + dot,
  Ionicons chip X, and a shared IssuesLoading skeleton — drops hardcoded
  #71717a and react-native-svg usage on these surfaces
- new useClearFiltersOnWorkspaceChange hook + IssuesLoading component
  shared across both surfaces (three-occurrence threshold respected)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:12 +08:00
Naiyuan Qing
53ee5be68a docs(mobile): hoist "existing pattern first" to Principle 1 in UI rules
So AI agents grep the codebase for an analogous component before reaching
for RNR add or hand-rolling — structural fix for the pre-migration legacy
(21 hand-written components, 18 sheets) that accumulated by treating each
new screen as a blank slate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:41:40 +08:00
Naiyuan Qing
06cb4674e0 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 18:12:42 +08:00
Naiyuan Qing
08a8f386d7 Merge remote-tracking branch 'origin/main' into feat/mobile-ios 2026-05-19 17:34:36 +08:00
Naiyuan Qing
dbb8c0ade0 refactor(mobile): 5-tier surface elevation scale — fixes comment-bubble nested contrast + inline-code link confusion
Two related fixes that share root cause: shadcn's neutral palette
collapses `secondary` / `muted` / `accent` to the SAME L 96.1% value
intentionally — it's a single tonal slot whose semantic name varies by
use case, not three different colors. Stacking a bg-muted child on a
bg-secondary parent (which is what we were doing for code/table headers
inside the comment bubble) made the inner element visually disappear.

Introduce a proper 5-tier elevation scale calibrated to Refactoring UI
and Material 3 guidance:

  L 100   page bg / card / popover            (page floor)
  L 98    surface-1   NEW                     (subtle elevated — comment
                                               bubbles, iOS settings-cell
                                               feel: visible boundary
                                               via radius + border, fill
                                               is almost-page)
  L 96.1  secondary / muted / accent          (shadcn default, untouched —
                                               button hover, chips, skeleton)
  L 90    surface-2   NEW                     (nested inside surface-1 —
                                               table headers + code blocks
                                               inside comment bubbles, 8% L
                                               step over surface-1)
  L 84    border      (was 89.8% → 84%)       (visible across every tier,
                                               6-16% darker than adjacent
                                               surface, within Refactoring
                                               UI's 5-10% guideline)

Dark mirror flips the lightness direction (higher elevation = lighter):
page 3.9 → surface-1 8 → secondary 14.9 → surface-2 19 → border 25.

Applied across three files:

  - global.css + tailwind.config.js + lib/theme.ts mirror the new tokens
    (CSS variables, Tailwind class map, TypeScript export — they must
    stay in sync per CLAUDE.md §5).

  - components/issue/comment-card.tsx switches the bubble bg from
    `bg-secondary` (too prominent, same color as inner muted elements)
    to `bg-surface-1` (subtle, 8% lighter than inner surface-2).

  - lib/markdown/markdown-style.ts:
      - table.headerBackgroundColor + codeBlock.backgroundColor:
        `t.muted` → `t.surface2`, so they're framed against the bubble.
      - inline `code:`: REVERT 2026-05-19's `color: t.brand` workaround
        for upstream enriched-markdown #255. The brand-tint avoided the
        chip's top-heavy padding artifact but broke Refactoring UI's #1
        rule (color carries semantic meaning — brand IS the link color,
        users reported tapping inline code thinking it was a link).
        Re-enable bg-chip + foreground text, matching GitHub mobile /
        Slack / Notion / Apple Notes. The padding artifact is the lesser
        evil; in surface-2 (L 90%) on surface-1 (L 98%) the chip is
        subtle enough that the few pixels of asymmetry are unobtrusive.

The shadcn `secondary` / `muted` / `accent` tokens stay at L 96.1%
unchanged — other call sites (button hover, skeleton, avatar fallback,
chips) all work fine on their own and were never the problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:41 +08:00
Naiyuan Qing
27c171f30c feat(mobile): attribute row — project picker wired + all pickers go pageSheet
Issue-detail AttributeRow chip row (status / priority / assignee / label /
project / due-date) had three nagging gaps. Fix them together so the
whole row behaves consistently.

  - ProjectPickerSheet was never wired: the file existed (155 lines, ready
    to use) but the chip was read-only with a stale `// picker deferred
    until web ships one` comment. Web has had a project picker forever.
    Add the projectOpen state, an `onProject` handler that calls
    `useUpdateIssue.mutate({ project_id })`, a placeholder dimmed chip
    when no project is set, and mount the sheet. Mobile users can now
    change an issue's project.

  - PRIORITY_LABEL was duplicated in two places — re-declared inside
    priority-picker-sheet.tsx (full form `none: "No priority"`) and as a
    near-identical chip placeholder in attribute-row.tsx (short form
    `none: "Priority"`). Both now import from the single source in
    `lib/issue-status.ts`; attribute-row keeps a 1-key override
    (`PRIORITY_CHIP_LABEL = { ...PRIORITY_FULL_LABEL, none: "Priority" }`)
    so the chip placeholder still reads as a placeholder, not as an
    assigned value.

  - Sheet container split was inconsistent: assignee / label / project
    pickers used SheetShell pageSheet (slide-up from bottom), while
    status / priority / due-date used a centered transparent Modal card
    (different gesture, different position). For a chip row where users
    tap several pickers in succession, the inconsistency broke iOS
    muscle memory. Status / priority / due-date all switch to pageSheet
    so the whole row reads as "tap chip → slide-up sheet" uniformly.
    Linear iOS / Things 3 / Apple Reminders use this pattern even for
    short fixed lists.

CLAUDE.md Lesson #6 modal container table grew a "picker-row consistency
wins over per-container optimisation" carve-out so future row-of-pickers
work follows the same rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:15 +08:00
Naiyuan Qing
b39e3a90c0 fix(mobile): activity ↔ web parity — start_date / squad_leader / wording
Five small fixes that close the remaining gaps between mobile's activity
rendering and the web equivalent in packages/views/issues/components/
issue-detail.tsx. All logic-layer; no component or container changes.

  - timeline-coalesce.ts: add NEVER_COALESCE_ACTIONS = {squad_leader_
    evaluated}. Without it, two consecutive squad-leader evaluations from
    the same actor within 2 min merged into one row, dropping the second's
    `outcome` + `reason` audit fields. Web does this since the rule was
    added; mobile was missing it.

  - format-activity.ts: add cases for `start_date_changed` (set / remove
    branches) and `squad_leader_evaluated` (outcome × reason 4 branches).
    Before, both fell through to the default that returns the raw enum
    name — users saw literal `start_date_changed` / `squad_leader_
    evaluated` strings in the timeline.

  - format-activity.ts: tighten assignee wording from "assigned NAME" to
    "assigned to NAME" — matches web's en/issues.json copy.

  - activity-row.tsx: `LeadIcon` now reuses CalendarGlyph for
    `start_date_changed` (same affordance as `due_date_changed`).

  - components/inbox/detail-label.tsx: TYPE_LABEL Record was missing
    `start_date_changed` — fixes a pre-existing TS error.

  - data/schemas.ts: EMPTY_ISSUE_FALLBACK was missing `start_date: null`
    — fixes the other pre-existing TS error. Both gaps had the same root
    cause (backend added the field, mobile didn't follow).

Typecheck is now clean — no pre-existing errors remaining.

Copy strings mirror packages/views/locales/en/issues.json verbatim
(activity.start_date_set / squad_leader_action / etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:31:57 +08:00
Naiyuan Qing
e9b2e98d15 fix(mobile): PulseDot uses brand colour, not success — running ≠ completed
The agent "is working" pulse dot (shown both in the issue Stack header
ambient badge and in the in-card AgentActivityRow "Working" row) was
backgroundColor #22c55e — that's the success/completed token. Reading
green here meant "task complete", which is the opposite of what the
animation represents.

Switch to THEME[scheme].brand (hsl(225 71% 58%)), matching:
  - mobile RunRow status text: STATUS_CLASS.running = "text-brand"
  - web agent-live-card.tsx:327: <Loader2 text-info animate-spin />
  - Apple HIG / shadcn semantic colour convention:
      green = success, blue/brand = in-progress, red = destructive

One-line fix in pulse-dot.tsx; both call sites (AgentHeaderBadge top-right,
AgentActivityRow under the title) flip from green to brand blue
together. Docstring updated to spell out the rule for future readers:
DO NOT use success here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:41 +08:00
Naiyuan Qing
9aaff146b5 refactor(mobile): close button uses <IconButton variant=secondary>
Both the SheetShell (pageSheet header) and the standalone ModalCloseButton
(modal Stack header) were drawing the circular grey close ✕ by hand:
<Pressable> + <View bg-secondary> + <Ionicons color="#3f3f46">. Two
problems with that pattern:

1. The #3f3f46 zinc-700 hex is invisible in dark mode — the icon and
   background both go dark, contrast collapses.
2. It bypasses RNR Button (which is exactly what an icon button is),
   re-implements active state, and lives outside the design system.

Swap both to <IconButton name="close" variant="secondary"
className="size-7 rounded-full"> — RNR Button under the hood, secondary
variant carries the bg-secondary token (so dark mode flips), icon color
comes from useTheme(). className locks the 28pt circular shape that
Linear iOS / Things 3 use for this slot (RNR's default size="icon" is a
40pt rounded-md square box, which is a different look).

One-line fix per file, no new primitive. Affects every pageSheet
close button (RunsSheet, picker sheets via sheet-shell) and every modal
close button (new-issue, search, new-comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:23 +08:00
Naiyuan Qing
95979515b2 feat(mobile): issue detail — comment-as-modal route, hex/Pressable cleanup, API helpers
Comment composer redesign (user feedback: inline always-on was clunky,
keyboard avoidance bad, no room for @mention suggestion bar). The bottom
of issue/[id].tsx is now a single <Button>Comment</Button>; tap pushes
the new issue/[id]/new-comment modal — full screen for typing,
AutosizeTextArea + MentionSuggestionBar + toolbar. Reply path goes
through the same modal with parent / parentName route params, so
"Reply" on a comment long-press just pushes the modal in reply mode.

Comment-card long-press no longer competes with iOS native text
selection: wrapped <Markdown> in a View with userSelect:'none' so the
press only triggers the action sheet. Users can still copy the full
comment body via the existing "Copy text" entry.

issue/[id].tsx headerRight 3-dot menu switches from a hand-drawn
Pressable + Ionicons (hardcoded #0a84ff/#71717a) to <IconButton>. Same
hex cleanup applied to:
  - agent-activity-row.tsx (2× #a1a1aa → THEME.mutedForeground)
  - activity-row.tsx (MUTED constant deleted; SVG glyph takes stroke prop)
  - comment-card.tsx BRAND_RING/BRAND_WASH rgba constants gone — animated
    overlays now use NativeWind border-brand/50 + bg-brand/5 classes,
    opacity stays the only animated channel.

API layer: 5 issue GET methods migrated to fetchValidated (getIssue,
listTimeline, listAttachments, listActiveTasksForIssue, listTasksByIssue).
Write endpoints stay on raw this.fetch per the existing mobile convention
— migrating writes needs new zod schemas, defer to a follow-up PR.

comment-composer.tsx deleted: orphan after the modal swap. CommentActionSheet
is kept as-is — it has the quick-react emoji row (the only "add reaction"
entry for comments) and already follows the correct Lesson 6 short-action
card pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:09 +08:00
Naiyuan Qing
fcd99c0084 docs(mobile): encode helper-layer conventions + swipe & Tier C lessons
CLAUDE.md grew with rules surfaced by the inbox PR + the earlier WS / API
helper work, so future agents can find the helpers instead of recreating
them.

New section "Data layer helpers" — three rails (logic mirrors web; use
existing components, don't invent primitives; use the wrapped request
layer) + helper-by-helper reference (fetchValidated, fetchValidatedWith,
xKeys factory shape, ws.on<E>() + WSEventPayloadMap, useWSSubscriptions,
synchronous-setQueryData-before-await ordering) + a 7-step checklist for
new features.

Realtime strategy extended with "Cross-cutting cache patches across
features" — the rule that issue:* → inbox-cache patches live in
inbox-ws-updaters.ts (owned by the feature being patched), not in issues'
own hook. Reconnect table updated to use inboxKeys.list(wsId).

Two new Lessons:
  - Lesson 7: destructive swipe is reveal-only, never auto-fire; haptic
    via useAnimatedReaction + runOnJS at the threshold. Encoded from the
    inbox PR's swipe UX fix.
  - Lesson 8: Tier C domain components (ActorAvatar, StatusIcon, etc.)
    upgrade opportunistically — don't silently rewrite when you're just
    rendering them in a new feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:21 +08:00
Naiyuan Qing
ace56f80c8 feat(mobile): inbox refactor — Mark all read, swipe UX, parity fixes
Swipe-to-archive no longer auto-fires on full drag (felt aggressive, no
peek, easy mistrigger on fast scroll). Now matches iOS Mail / Linear: drag
reveals the red Archive button + medium haptic at threshold, user taps to
commit. Auto-fire path removed; useAnimatedReaction + runOnJS bridges the
UI-thread shared value to Haptics.impactAsync.

Behavioral parity fixes the previous mobile inbox was missing vs web:
  - Mark all read action — endpoint POST /api/inbox/mark-all-read already
    existed server-side; mobile just never wired it. Added api.markAllInbox
    Read + useMarkAllInboxRead (optimistic flip read=true on non-archived)
    + ActionSheet menu entry as the first option.
  - issue:updated → patch inbox row's StatusIcon inline. Previously mobile
    ignored the event and showed stale status until the next inbox event
    refetched the list.
  - issue:deleted → strip orphaned inbox rows so tapping doesn't 404 on
    the issue detail page.
  - Both via a new mobile-owned inbox-ws-updaters.ts mirroring web's
    packages/core/inbox/ws-updaters.ts.

Internal cleanup:
  - inboxKeys factory in data/queries/inbox.ts ({all,list}, 3-segment
    shape matching web). 6 inline ["inbox", wsId] strings retired across
    queries / mutations / realtime / useCreateIssue inbox invalidate.
  - Synchronous setQueryData hack (workaround for iOS push transition
    snapshot capturing pre-flip state) moved from inbox.tsx caller into
    useMarkInboxRead.onMutate. Every caller benefits, none can forget it.

UX polish:
  - Loading state: 6 Skeleton rows (RNR, installed this PR) replacing
    centered ActivityIndicator.
  - Empty state: mail-open icon + helper text replacing bare "No inbox
    items." copy.
  - ItemSeparatorComponent ml-[60px] → ml-16 (token, aligns with avatar
    36 + px-4 + gap-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:07 +08:00
Naiyuan Qing
f5dbcad533 feat(mobile): settings — profile + notifications subscreens, RNR primitives, API helpers
Settings page rewritten to use RNR primitives (RadioGroup, Switch,
Avatar, Separator) instead of self-drawn equivalents, removes 3
hardcoded #71717a hex colors in favor of THEME tokens, and adds
Alert.alert confirmation on sign-out with destructive Button variant.

Two new push subscreens under more/settings/:
  - profile.tsx     edits name + avatar. Avatar tap opens iOS native
                    ActionSheetIOS (Take Photo / Library / Remove) via
                    expo-image-picker, then PATCH /api/me.
  - notifications.tsx  5 inbox groups + system_notifications toggle,
                       backed by optimistic PUT /api/notification-preferences.

New mobile-owned query + mutation for notification preferences mirror
the web design (no runtime import — per CLAUDE.md "Mobile-owned
updaters"). auth-store gets setUser action for in-memory user update
after profile PATCH.

ApiClient gains fetchValidated + fetchValidatedWith private helpers
that collapse the fetch+parseWithFallback envelope. 4 settings-related
methods migrated as canary (getMe, updateMe, getNotificationPreferences,
updateNotificationPreferences); remaining 30+ read methods migrate
progressively in later PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:34:43 +08:00
Naiyuan Qing
0de3d4012c refactor(mobile): typed ws.on<E>() + useWSSubscriptions to cut realtime boilerplate
Adds WSEventPayloadMap in @multica/core/types so callers get the precise
payload type per event — no more `const p = msg as IssueUpdatedPayload`
boilerplate at every handler. Mobile ws-client adopts the generic
signature; web's untyped on() is untouched but can opt in later.

useWSSubscriptions wraps the if-ws-and-wsId-then-useEffect-cleanup
template every Layer-3 realtime hook used to repeat. Each of the 8 hooks
sheds ~7 lines of lifecycle scaffolding and ~30 total `as Payload` casts
go away; only 1 deliberate cast stays for the cross-event onTaskEvent
(task:progress has no formal payload interface yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:34:23 +08:00
Naiyuan Qing
abd9af96d3 fix(mobile): resolve mc:// image URIs against attachment list before render
Markdown content authored in Multica stores image references as
`mc://file/<id>` rather than baking signed HTTPS URLs into the text
(signed URLs expire). iOS image loader doesn't understand the `mc:`
scheme, so any attachment-image in a description, comment, or chat
message was raising a redbox: "No suitable image URL loader found for
mc://file/...".

Web already resolves this via `packages/views/editor/
attachment-download-context.tsx`: components look up the markdown URL
in the issue's attachment list and use the matching `download_url`.
This commit mirrors that pattern for mobile.

The wiring:

  - `data/schemas.ts` — AttachmentListSchema + EMPTY_ATTACHMENT_LIST
  - `data/api.ts` — listAttachments(issueId) → GET /api/issues/:id/attachments
  - `data/queries/issue-keys.ts` — `attachments(wsId, id)` key
  - `data/queries/issues.ts` — issueAttachmentsOptions
  - `lib/markdown/markdown.tsx` — Markdown accepts `attachments?` and
    forwards to MarkdownImage
  - `lib/markdown/markdown-image.tsx` — looks up uri in attachments,
    swaps for `download_url`; unresolved URIs fall through and fail
    the getSize callback gracefully (16:9 muted placeholder, no
    redbox)
  - `IssueDescription` and `CommentCard` — fetch via
    issueAttachmentsOptions; TanStack Query dedupes so the same
    issue's attachment list only fires one request regardless of how
    many components need it
  - `chat-message-list` — passes `message.attachments` directly (chat
    messages carry their attachment list on the message record itself,
    distinct from the issue-scoped model)

Unmatched URIs (e.g. test placeholders like `file_abc123`) now render
the same muted 16:9 fallback as a 404 — never a redbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:35:43 +08:00
Naiyuan Qing
125151e2c4 refactor(mobile): simple Header + IconButton, drop ScreenHeader / ChatHeader
Tab and stack screens were carrying two hand-rolled header components
(ScreenHeader, ChatHeader) that reimplemented enough of UINavigationBar
to ship the obvious bugs: hardcoded hex colors that didn't follow the
NativeWind dark scheme, no shared dark/light token wiring, no consistent
touch feedback for action buttons (Pressable + custom className per
call site).

This commit collapses both into one shared component family:

  - `components/ui/header.tsx` — slot-based (`title` / `center` / `left`
    / `right`) rendered in the screen's JSX. Self-handles the top safe
    area, uses semantic RNR tokens (`bg-background`, `text-foreground`,
    `border-border`) so dark mode flips via NativeWind class mode with
    no per-screen logic.
  - `components/ui/icon-button.tsx` — `<RNR Button variant="ghost"
    size="icon">` wrapping an Ionicon whose color falls back to
    `useTheme().colors.text` (the active navigation theme), so the
    glyph follows dark/light automatically without callers passing
    a color prop.
  - `components/chat/chat-title-button.tsx` + `chat-session-actions.tsx`
    — chat-specific slots that plug into the same Header (center +
    right) instead of the chat tab having its own complete header.

Call sites:
  - Inbox / My Issues / Chat / more/issues — drop `<ScreenHeader>` and
    `<ChatHeader>`, render `<Header ...>` at the top of the screen body
    with the appropriate slot contents.
  - HeaderActions — Search / New-Issue buttons swap raw Pressable for
    IconButton. The previously-added Menu button is removed (redundant
    with the "More" tab in the bottom bar).
  - more/issues — was rendering both the workspace stack's native
    header AND its own ScreenHeader inside the screen body, so the
    filter button now goes onto the stack header via
    `navigation.setOptions({ headerRight })` and the in-body header
    is gone.

Why the per-tab Stack approach (briefly explored) was abandoned:
react-navigation's native large title is the only thing that needed a
Stack per tab, and the product doesn't want collapse-on-scroll. With
that gone, every dynamic header content piece (Inbox's archive menu,
Chat's agent picker title) was forced through `navigation.setOptions`
in a useLayoutEffect — strictly more complexity than just rendering
the Header in JSX with state passed as props.

Net: 349 lines removed, 208 added. Two header components deleted; two
small primitives added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:35:23 +08:00
Naiyuan Qing
8996e821b9 wip(mobile): markdown rendering tweaks — incomplete
Checkpoint commit. Markdown rendering refactor is in progress and not
yet producing the full expected output; committing so it isn't lost
alongside the RNR migration in the same tree. Will be finished in a
follow-up before push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:09:20 +08:00
Naiyuan Qing
420230c62a feat(mobile): adopt react-native-reusables + class-mode dark mode
First wave of the RNR migration documented in apps/mobile/docs/
rnr-migration.md. The hand-written components/ui/ shell was producing a
steady stream of dark-mode and sheet-handling bugs; this commit
establishes the foundation that lets every subsequent screen pick up
RNR-shipped components and a real theme system instead.

Foundation (Phase 1):
- global.css + tailwind.config.js switch to shadcn neutral CSS variables
  (light + dark) under :root and .dark:root, with Multica custom tokens
  appended. tailwind utilities resolve to hsl(var(--...)).
- New lib/theme.ts mirrors the variables in TypeScript and exports
  NAV_THEME for React Navigation chrome.
- New lib/use-color-scheme.ts wraps NativeWind's useColorScheme with
  expo-secure-store persistence (preference key: theme-preference,
  values: light/dark/system).
- components.json registers shadcn CLI paths so `npx @rnr/cli add` writes
  to the expected aliases. metro.config.js gains inlineRem: 16.
- app/_layout.tsx wraps the tree in ThemeProvider(NAV_THEME[scheme]) and
  mounts <PortalHost /> for RNR dialogs.
- Settings → Appearance picker (three rows: Light / Dark / System,
  persisted) — the only product addition in this commit.

Component canary (Phase 2):
- button.tsx + text.tsx replaced by RNR's defaults via the CLI (uses
  TextClassContext to flow text variants from Button into nested Text).
- 11 button call sites updated to wrap children in <Text> (the RNR
  convention). The old `brand` variant had zero call sites and was
  dropped without follow-up.

Bottom navigation:
- (tabs)/_layout.tsx tried NativeTabs first but rolled back to JS Tabs:
  NativeTabs hard-codes canPreventDefault: false on tabPress events, so
  the "More tap opens a sheet without navigating" pattern was
  unreachable. The rolled-back layout uses useColorScheme + THEME to
  derive active/inactive tint, fixing the dark-mode "dim selected tab"
  bug.
- More tab intercepts tabPress and pushes /[workspace]/menu — a stack
  route registered with presentation: "formSheet" +
  sheetAllowedDetents: "fitToContents" so iOS sizes the sheet to the
  menu's intrinsic height (UIKit handles drag handle, swipe dismiss,
  blur backdrop).
- The formSheet route is named `menu.tsx` rather than `more.tsx` to
  avoid the URL collision with (tabs)/more.tsx — both files would
  otherwise resolve to /[workspace]/more because (tabs) is a transparent
  route group.
- components/nav/global-nav-menu.tsx refactored from a self-managed
  Modal into a plain ScrollView (no flex-1, so fitToContents can
  measure). Closes via router.dismiss() instead of an onClose prop.

Docs / rules:
- apps/mobile/CLAUDE.md adds two hard rules: "defaults first" and "iOS
  native > RNR > discuss" (the three-tier waterfall).
- apps/mobile/docs/rnr-migration.md captures the alternatives evaluated,
  the three-tier component classification, the phased rollout, and the
  pitfalls hit during this commit.

Out of scope for this wave (planned but not started):
- Tier A remaining primitives (input / card / text-field / textarea)
- Tier B sheets (the 18 hand-rolled Modal sheets — to be replaced one
  PR at a time with ActionSheetIOS / native pickers / RNR Dialog)
- Tier C domain UI internal-token upgrades

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:09:00 +08:00
Naiyuan Qing
ca9004961d Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-19 08:37:02 +08:00
Naiyuan Qing
e4ecb722fe docs(mobile): correct Debug-vs-Release standalone claim and env reload semantics
Two corrections to docs landed earlier this branch:

- The README told self-host users that ios:device:staging "runs without
  the Mac after the build completes." That is wrong for the Debug build
  it produces: every launch the embedded expo-dev-launcher probes Metro,
  showing a "Downloading…" / Recently-opened screen and stalling when the
  Mac is asleep or unreachable. Split the section into two paths and
  recommend the new :release variant for standalone use.

- The .env.example said changing a value "requires re-running an ios:*
  build" and that "dev:* (Metro) alone will not refresh baked-in values."
  That is only true for an installed Release build. For Debug, restarting
  Metro is sufficient — it re-reads .env on startup and inlines the new
  values into the next JS bundle it serves. Rewrite the comment to
  distinguish the two cases.

Also drop stale references to the removed ios:mobile:sim* scripts from
the env example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:27:54 +08:00
Naiyuan Qing
de235fd5fd feat(mobile): add ios:device:staging:release build script
Adds a Release-configuration build path for the staging variant:

  pnpm ios:mobile:device:staging:release
  → cd apps/mobile && expo run:ios --device --configuration Release

Release builds strip `expo-dev-launcher` from the binary (it's only
linked in the Debug Pod configuration), so the installed app loads the
embedded JS bundle directly — no "Downloading…" screen, no Metro
probe, no Recently-opened launcher menu. Standalone use feels like an
App Store install.

The existing `ios:device:staging` (Debug) path is unchanged — it stays
the daily-driver for hot-reload development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:27:31 +08:00
Naiyuan Qing
87cc3093ab fix(mobile): chat tab badge stuck or self-clearing in background
Two paired bugs in the auto-markRead effect:

1. A `lastMarkedRef` short-circuited every re-fire of the effect, so once
   a session was marked read, a subsequent chat:done arriving on the same
   session left the badge stuck at 1 forever.

2. With (1) gone, the effect re-fired even while the Chat tab was
   backgrounded (React Navigation keeps sibling tabs mounted), silently
   clearing unread state the user never had a chance to see.

Mirror web's chat-window.tsx logic: gate on `useIsFocused()` (mobile's
analogue of web's `isOpen`), and rely on has_unread itself as the dedup
signal — the mutation's optimistic patch flips it false immediately, so
the effect won't re-fire until the next chat:done flips it true again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:35:01 +08:00
Naiyuan Qing
e8c4c87741 docs(mobile): env example documents all six build/dev scripts
Previous template only mentioned the two dev:mobile* (Metro) scripts.
Now lists all six commands that read .env.development.local / .env.staging,
and flags the compile-time-baked gotcha: changing a value requires a
re-run of an ios:* build before an installed app sees the new value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:21:30 +08:00
Naiyuan Qing
3d0c87d84f fix(mobile): remove alpha channel from app icon
iOS app icons must not have an alpha channel — transparent backgrounds
can render as a blank/default icon on the device home screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:21:24 +08:00
Naiyuan Qing
48f912c3ea chore(mobile): add iOS app icon (shared 1024x1024 with desktop)
Adds apps/mobile/assets/icon.png (copy of apps/desktop/build/icon.png,
1024x1024 RGBA) and points the Expo config at it. Resolves the
\"No icon is defined in the Expo config\" warning on prebuild / EAS build.

Single-source: any brand refresh updates desktop's icon, then mirrors
into apps/mobile/assets/. Expo prebuild generates every required iOS
icon size from this one PNG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:01:17 +08:00
Naiyuan Qing
332ac868ce fix(mobile): escape apostrophes in login + select-workspace copy
CI lint failed on react/no-unescaped-entities. Two pre-existing JSX
literals contained raw apostrophes; replace with &apos;.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:54:44 +08:00
Naiyuan Qing
7bd13c83c0 docs: add iOS mobile client section + apps/mobile/README
Adds a pointer from the root README (EN + zh) to apps/mobile/, plus a
mobile-specific README covering scripts, env files, and the build-onto-
your-own-iPhone path for self-hosters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:49:14 +08:00
Naiyuan Qing
8811f72bc2 feat(mobile): squad @-mention support in issue composer
Adds squad rows to the @-mention suggestion bar — picker / serializer /
actor name lookup. Selecting a squad emits a `mention://squad/<uuid>`
token; backend wakes the squad's leader. Mirrors web's mention extension
(packages/views/editor/extensions/mention-suggestion.tsx): alphabetical
sort, archived hidden, distinct "Squad" badge.

Also adds a presence dot to the agent suggestion row in the same bar
(opt-in showPresence prop on ActorAvatar, mirroring 12 other call sites
on this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:49:05 +08:00
Naiyuan Qing
e746b1b3bd feat(mobile): ambient agent-working badge in issue header
Adds an always-visible "agent is working" indicator next to the issue
detail Stack header — a small AvatarStack + green PulseDot that opens the
Runs sheet on tap. Pairs with the existing in-card AgentActivityRow, which
is the first-time discovery surface; the header badge is the ambient
surface that stays put while the user scrolls the timeline (agent tasks
run minutes to tens of minutes).

Refactors AgentActivityRow + RunsSheet to dispatch through a shared
useRunsSheetStore (Zustand), since the Stack-header tree and the page-body
tree can't share local React state across that boundary on Expo Router.

Rationale: Apple HIG "Progress Indicators" + agent-UX ambient status
pattern. See plan /Users/qingnaiyuan/.claude/plans/ok-plan-linked-taco.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:45 +08:00
Naiyuan Qing
48282fa973 feat(mobile): agent presence dots + offline banner
Mirrors web's agent presence semantics (packages/core/agents/derive-presence.ts)
on iOS: 3-state availability (online / unstable / offline) derived from
runtime.status + last_seen_at + task snapshot, with a 30s wall-clock tick so
the 5-min unstable window decays without new server data.

Pure derivation imported from @multica/core/agents (whitelisted). React glue
(hook + WS + UI) is mobile-owned per the Sharing Principles in
apps/mobile/CLAUDE.md.

Wired into 12 avatar call sites via an opt-in showPresence prop:
chat-header / agent-picker / session-sheet / inbox-row / issue-row /
attribute-row / create-form-attribute-row / comment-card / run-row /
project lead + picker. Chat composer gets an OfflineBanner above it that
stays silent during loading.

Two mobile-specific tweaks vs web:
- 30s tick is AppState-gated and forces a recompute on foreground resume
  (iOS freezes JS timers in background).
- daemon:heartbeat / task:progress / task:message are explicitly skipped
  from the WS invalidation list — high-frequency events would burn cellular
  data; web already documented this footgun in use-realtime-sync.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:48:32 +08:00
Naiyuan Qing
7c00f88f68 Merge remote-tracking branch 'origin/main' into feat/mobile-ios
# Conflicts:
#	pnpm-lock.yaml
2026-05-15 16:03:29 +08:00
Naiyuan Qing
38313a62f7 feat(mobile): OTP code input + resend cooldown on verify screen
Replace the generic Input on the email-verify screen with a 6-slot
SF-styled OTP component (`input-otp-native`). Auto-submits on the
final keystroke instead of requiring a tap on the Verify button, and
exposes a `clear()` ref so the input resets after a server-side
rejection.

Add a 60-second resend cooldown with a live countdown beneath the
input, calling `auth.sendCode` on tap. Clears the previous code +
error when a new code is requested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:51 +08:00
Naiyuan Qing
db83cf9bfb refactor(mobile): always-on issue comment composer
Drop the tap-to-expand pill state machine. The composer now mounts in
its full form (input + @ / 📷 / 📎 / Send action row) immediately, with
no compact-pill intermediate state. Tap focuses the input and opens the
keyboard directly.

The pill→expand pattern was added to mirror chat composer's two-state
UX, but on a primary input surface like comments it is pure friction:
the user always has to tap once to get the affordance they came to use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:40 +08:00
Naiyuan Qing
0ea776e14e feat(mobile): swap tab bar icons to SF Symbols
Use expo-image's `sf:` source URLs for the four tab icons (tray /
checklist / bubble.left / ellipsis) instead of Ionicons. Native SF
Symbols render at the iOS standard tab-bar weight and stroke, so the
bar matches first-party iOS apps visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:29 +08:00
Naiyuan Qing
5a196225b8 refactor(mobile): simplify TextField primitive
Strip the four cross-platform RN TextInput workaround comments down to
the two notes that still apply. Anchor height with `h-10` instead of
`paddingVertical: 0`, and inline `fontSize` to avoid NativeWind mapping
to fontSize+lineHeight (RN clips descenders when lineHeight is set on
iOS TextInput).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:02:21 +08:00
Naiyuan Qing
eeac71233e fix(mobile): close API schema + polymorphic-actor parity gaps
Three real bugs uncovered by the apps/mobile/ code review, all unprotected
by parseWithFallback or by the actor/assignee polymorphism:

- ActorAvatar + useActorLookup did not accept "system" actors. Inbox items
  with actor_type="system" (platform-triggered notifications) rendered a
  blank circle. Add a system glyph branch + widen the lookup signature.

- AssigneeValue was narrowed to "member" | "agent", silently dropping
  squad assignments coming from web/desktop and preventing the user from
  clearing them on mobile. Widen to IssueAssigneeType and render squad
  assignees with a generic group glyph (no squad list query yet — picker
  still lists members + agents only, but Unassigned now clears squads).

- Six read endpoints (getMe, listWorkspaces, listInbox, listMembers,
  listAgents, getIssue) returned bare fetch<T>() casts with no schema
  validation, violating the "API Response Compatibility" rule that
  installed-app architectures depend on. Add zod schemas with .loose()
  and enum-drift .catch() defenses, plus EMPTY_* sentinels so drift
  downgrades to "stale defaults render" instead of crashing the boot
  sequence.

Also fixes the AttachmentSchema typecheck failure by adding the missing
chat_session_id and chat_message_id fields (mobile schema had drifted
from packages/core/types/attachment.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:00:09 +08:00
Naiyuan Qing
794c3a1a98 feat(mobile): issue detail delete via three-dot header menu
Issue detail had no headerRight menu, leaving users unable to delete
issues from the phone. Adds the same ActionSheetIOS pattern the project
detail screen already uses: Copy link / Open on web / Delete (red,
Alert-confirmed). Property edits stay on IssueHeaderCard chips — one
entry per action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:23:03 +08:00
Naiyuan Qing
c1452c7330 feat(mobile): inbox swipe-to-archive + batch menu
Closes the inbox archive gap on mobile — desktop made archive a
first-class action (hover icon + batch dropdown) but mobile had no
archive entry point at all. Adds the canonical iOS pattern: left-swipe
on a row reveals a destructive Archive button, full swipe auto-fires.
Header gains a three-action menu for "archive all read / completed /
all" mirroring the desktop dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:22:45 +08:00
Naiyuan Qing
11913d18f4 feat(mobile): show agent runs on issue detail
New double-state row inside IssueHeaderCard (between title and
attributes): "[👤👤👤] Working" + pulse dot when ≥1 active task,
"Runs · N" when only past runs exist, hidden otherwise. Tap opens a
pageSheet listing Active + Past runs with status badges and an inline
Cancel button on active rows.

Data layer:
- api.ts: listActiveTasksForIssue (GET /api/issues/:id/active-task)
  and listTasksByIssue (GET /api/issues/:id/task-runs), both run
  through parseWithFallback + a new AgentTaskSchema (lenient enums
  with .catch() for forward-compat)
- queries/issue-keys.ts + queries/issues.ts: activeTasks + tasks
  options, workspace-scoped, signal forwarded
- mutations/issues.ts: useCancelTask with optimistic remove + rollback
- realtime/use-issue-realtime.ts: task:* WS events now invalidate the
  two new task queries (in addition to detail+timeline), so the row
  and sheet update without polling

New components: AgentActivityRow (the row), RunsSheet (built on
SheetShell), RunRow (single task row, cancel action), AvatarStack
(mobile-native overlapping avatars).

Transcript drilldown deferred to a follow-up — past row tap is no-op
in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:11:12 +08:00
Naiyuan Qing
f9cab33d94 refactor(mobile): standardize sheets on iOS pageSheet via SheetShell
The 16 Modal-based sheets in apps/mobile/ all copy-pasted the same
transparent-fade + hand-drawn backdrop + maxHeight pattern from the
project's first sheet. That shape is right for short action menus but
wrong for content viewing / search / forms — each subsequent sheet hit
its own bug (keyboard squash, FlatList clipping, useSafeAreaInsets
returning 0 inside Modal, "floating" feel from transparent backdrop).

Introduce SheetShell — a shared primitive wrapping Modal
presentationStyle="pageSheet" + nested SafeAreaProvider + header
(title + X) + safe-area-aware body. Migrate 7 misclassified sheets:
session, issue-filter, assignee/label/project/project-lead pickers,
add-resource. Codify the container-selection rule as CLAUDE.md Lesson
#6 so the next sheet doesn't inherit the wrong shape.

A-class sheets (comment-action, emoji-picker, fixed-option pickers)
intentionally left alone — their content matches the original pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:11:00 +08:00
Naiyuan Qing
6519e23282 feat(mobile): comment composer tap-to-expand two-state UX
CommentComposer's previous "stacked horizontal bars" layout (replying-
to chip + 7-button MarkdownToolbar + TextInput row + floating Send)
looked nothing like the chat composer beside it and dominated ~120pt
of vertical space on the issue detail screen even when no one was
composing.

Rewritten as a compact pill that taps open into a chat-composer-shaped
floating card. State machine is blur-driven:

- compact + tap pill → expanded, focus TextInput via useRef + rAF
  (autoFocus on conditional render is unreliable across iOS/Android)
- expanded + onBlur + text empty + no replyingTo → collapse to compact
- expanded + onBlur + has text or replyingTo → stay expanded; draft
  visible, user can scroll the timeline without losing context
- send success resets text but does not collapse — next blur drives it,
  so back-to-back sends don't make the card jump

In-card action row mirrors chat: @ · 📷 · 📎 left, Send right.
File / image upload reuses useFileAttach and inserts the existing
markdown formats (![](url), [📎 name](url)) — no backend changes.

Drops MarkdownToolbar entirely (list/checkbox/code/quote) — users can
still type those by hand and the timeline renderer is unchanged. The
replyingTo chip moves to a rounded pill above the card (border-b would
have clashed visually with the rounded-3xl card geometry).

Also fixes a pre-existing race: canSend now gates on !fileAttach.
uploading so a deferred insertAtCursor can't land in an already-cleared
input. Hardens canCancelReply: blur the input when reply is cleared
with empty text, so the existing collapse rule fires uniformly without
forcing manual keyboard dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:55:23 +08:00
Naiyuan Qing
af2369002c feat(mobile): TextField + AutosizeTextArea primitives
Mobile had 16 bare <TextInput> sites and a shared <Input> component
that nothing used. Every screen author repeated the four RN cross-
platform workarounds independently — paddingVertical:0, includeFont
Padding:false, textAlignVertical, and (for multiline) the onContentSize
Change + height-state dance — and most missed at least one.

This commit introduces two primitives that bake those in:

- <TextField> — single-line baseline with variant="filled" (default).
  Locks multiline={false} + numberOfLines={1} so callers can't mix
  iOS UITextField / UITextView modes by accident.

- <AutosizeTextArea> — multiline that actually grows with content,
  via onContentSizeChange → useState(height) clamp to [minHeight,
  maxHeight]. RN's Yoga doesn't read native intrinsicContentSize
  (facebook/react-native#54570, open), so this is the only way the
  bounding box keeps up with text. scrollEnabled flips on at the
  ceiling so a tall draft becomes internally scrollable instead of
  pushing the layout open.

Migrated 8 of 16 sites — chat composer, 3 description fields (new
issue, project new, project edit), and 4 picker sheets (label,
project, assignee, add-resource). Comment composer migration ships
in the follow-up commit since it's bundled with the redesign.

login / verify / search / hero titles + variant="outlined" / size="hero"
intentionally deferred (Out of Scope per plan) — no user-reported bug,
add them when the migration earns its weight.

<Input> is repurposed as a re-export of <TextField> so any future
import-by-name resolves to a sensible primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:55:03 +08:00
Naiyuan Qing
233fdd66bc feat(mobile): inbox → comment deep-link with flash highlight
When a user taps a new_comment / mentioned / reaction_added inbox row,
the issue detail screen now auto-scrolls to the target comment and
flashes it (matching web's behavior at packages/views/issues/components/
issue-detail.tsx:686-709). Replies are folded into their parent's
CommentCard, so a reply deep-link scrolls to the parent row and lights
up the matching child View only — mirroring web's replyToRoot fallback.

- Inbox tap now uses object-form router.push with highlight + h (nonce)
  params so re-tapping the same row re-fires the effect.
- TimelineList owns scrollToIndex (data-relative, viewPosition 0.3) with
  the standard onScrollToIndexFailed estimate-then-retry dance for
  variable-height rows.
- CommentCard renders an absolute-positioned Reanimated overlay
  (borderWidth + bg wash for root, bg-only for reply) driven by a single
  sharedValue with withSequence(700ms in, 1800ms hold, 700ms out) —
  matching web's transition-colors duration-700 + setTimeout(2500) timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:54:40 +08:00
Naiyuan Qing
542e6b461c fix(mobile): stop emoji clipping in ProjectIcon
Previous impl rendered the emoji as <Text leading-none>. On iOS, emoji
glyphs render ~10-15% larger than fontSize because they ignore latin
baseline metrics, and <Text> clips content to lineHeight — so the top
and bottom of every project emoji were being cut off. project-row.tsx
had a pt-0.5 compensation that only nudged the top, leaving the bottom
clipped and producing the "row height feels off" visual.

Wrap the Text in a fixed square View (sm=18 / md=22 / lg=28 px), set
explicit lineHeight = round(fontSize * 1.2) so the glyph has the room
it needs. Drop the pt-0.5 hack — the icon now self-centers cleanly and
flex parents using items-start / items-center align siblings against a
stable square footprint.

Affects every ProjectIcon call site: search rows, Projects list,
project header card, issue attribute / create-form rows, project
picker sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:27:23 +08:00
Naiyuan Qing
7aad870f06 feat(mobile): workspace search modal
Wires the header search icon to a working modal — debounced search
across issues + projects, Recent as empty state, modal-to-detail via
router.replace. Behavioral parity with packages/views/search but stays
search-only (no command-palette section) so it doesn't dual-list
targets already in the More popover.

- data/schemas.ts: SearchIssuesResponseSchema / SearchProjectsResponseSchema
  with enum-drift defense (match_source falls back to "title")
- data/api.ts: searchIssues / searchProjects with AbortSignal forwarding
  and parseWithFallback
- (app)/[workspace]/search.tsx: TextInput + 300ms debounce + abort,
  single FlatList driving Recent / Projects / Issues rows, snippet
  line for comment-matches mirrors web search-command.tsx:632

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:27:13 +08:00
Naiyuan Qing
2a3accb5d7 feat(mobile): unread badges on Inbox and Chat tabs
Surface the same unread signals web puts on the sidebar (inbox) and
the ChatFab (chat). On a phone the user lives on the tab bar, so
mounting badges directly on the Inbox and Chat tabs is the closest
equivalent.

Display semantics mirror web exactly (apps/mobile/CLAUDE.md "counts
must agree"):

- Inbox badge = `deduplicateInboxItems(items).filter(i => !i.read).length`,
  same as web's `useInboxUnreadCount` (packages/core/inbox/queries.ts:22).
  99+ truncation matches the sidebar.
- Chat badge = `sessions.filter(s => s.has_unread).length`, same as web's
  ChatFab (packages/views/chat/components/chat-fab.tsx:29). 9+ truncation
  matches the fab.

Implementation:
- New `apps/mobile/lib/unread-counts.ts` with two `useQuery + select`
  hooks; mirror-don't-import the web design.
- Wired into `(tabs)/_layout.tsx` as React Navigation's native
  `tabBarBadge` + `tabBarBadgeStyle`. Style is JUST `backgroundColor`
  (brand blue `#4571e0`); @react-navigation/elements `Badge` internally
  uses `borderRadius = size / 2` and `minWidth = size`, so the
  single-character badge renders as a true circle. Overriding minWidth /
  fontSize / fontWeight breaks that geometry — keep the override minimal.
- Brand blue chosen over the iOS default red: matches web's
  ChatFab `bg-brand` pip and avoids the "error / critical" connotation
  red carries for an everyday new-comment notification.

Both queries (`inboxListOptions`, `chatSessionsOptions`) are already
kept fresh by listing-level realtime hooks mounted in
`app/(app)/[workspace]/_layout.tsx` (`useInboxRealtime` /
`useChatSessionsRealtime`), so badges update via WS events without a
poll or focus refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:54:21 +08:00
Naiyuan Qing
235c9e82e2 fix(mobile): inbox row flips to read state before navigation push
Tapping an unread inbox row produced no visible "now read" feedback —
the row disappeared into the issue detail push transition still wearing
its unread bullet and bold-foreground style. Users came back via the
back button to find it had become read (correct cache state, just no
real-time feedback).

Root cause: `useMarkInboxRead.onMutate` does `await qc.cancelQueries`
before the optimistic `setQueryData`, so the optimistic write lands one
microtask after the synchronous `router.push`. iOS native stack
captures the source view screenshot at push time — the screenshot freezes
the row in its unread state, and the transition animates that frozen
frame regardless of any later cache write.

Fix: in `onPressItem`, do the optimistic `setQueryData` synchronously
right before calling `markRead.mutate(...)`. The mutation still runs
end-to-end (so the server PATCH fires and `onSettled` invalidate
reconciles), but the row already shows the read style on the frame
that gets screenshotted for the push transition. The tab-bar inbox
badge also drops one count at the same instant for the same reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:53:44 +08:00
Naiyuan Qing
f7e56577ab feat(mobile): default Chat tab to most recent session on first entry
Web's chat-window opens to an empty state when no activeSessionId is
persisted, because the sidebar SessionDropdown makes one-click switching
cheap. On a phone, picking a session is 4 taps (header → sheet open →
row → close), so an always-empty default is friction — users complained
they had to re-pick the session every cold start.

Mobile-only deviation: on the first Chat tab entry for a given
workspace, jump straight to the most recent session (`sessions[0]`,
server-sorted by `updated_at desc`). A per-workspace `useRef` flag
makes the hydration a one-shot — subsequent user intent (point + New,
delete-active) sets activeSessionId to null and is respected forever
after. When the user switches workspaces, the ref resets so the new
workspace gets its own first-entry hydration.

Behavioural parity is preserved: counts / visibility / permissions /
enums match web exactly. UX is allowed to diverge on UI mechanics per
apps/mobile/CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:53:20 +08:00
Naiyuan Qing
caa9c4946e fix(mobile): restore Chat list scrolling + align bubble UI with web
The Chat tab message list was unscrollable. Two distinct root causes
under the same surface symptom:

1. Wrapper hijacking the touch responder. chat.tsx mounted a
   Pressable around ChatMessageList to implement "tap empty area =
   dismiss keyboard". Any Touchable* (Pressable / TouchableWithoutFeedback /
   TouchableOpacity) claims the responder via the shared Touchable mixin
   and does NOT reliably hand it back to the child FlatList for pan
   gestures, killing scroll. Removed entirely — `keyboardShouldPersistTaps
   ="handled"` on the FlatList already provides the same behaviour per
   RN docs (a tap not handled by a child bubble dismisses the keyboard),
   and `keyboardDismissMode="interactive"` covers drag-to-dismiss. Mirrors
   web's bare `<div className="flex-1 overflow-y-auto">` mount.

2. `onContentSizeChange` re-sticking to bottom on every async layout.
   Markdown async rendering (Shiki highlight, image natural-size
   resolution, lightbox provider injection) fires content-size changes
   for seconds after first paint. The previous handler called
   `scrollToEnd` unconditionally, snapping the user back to the bottom
   the instant they tried to drag up. Replaced with a sticky-bottom
   state machine — `isAtBottomRef` / `userHasScrolledRef` /
   `firstMsgIdRef` — that only re-sticks while the user is anchored
   at the bottom; reading history is left alone. Same semantic as
   iMessage and web ChatWindow.

Bonus alignment with web's bubble styling:
- User bubble: bg-muted (was bg-primary dark), max-w-[80%] (was 88%),
  text-foreground.
- Assistant: w-full (was self-start max-w-[88%]) so Markdown / code
  blocks / tables get the full content width.
- Outer content padding: px-4 pt-3 pb-4 gap-3 (was px-3 py-3 gap-2),
  matching web's `max-w-4xl px-5 py-4 space-y-4` rhythm at mobile scale
  and giving the last bubble breathing room above the composer.
- FlatList itself gets `className="flex-1"` so its height is the
  remaining viewport in the KeyboardAvoidingView column, matching web's
  `flex-1 overflow-y-auto` host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:52:53 +08:00
Naiyuan Qing
f53a6936e3 refactor(mobile): drop paginated timeline, fetch as single ASC list
Server-side timeline pagination was retired (#2322) because p99 issues
have ~30 entries — cursors were pure overhead and split reply threads
across page boundaries. Mobile mirrors the new shape:

- `api.listTimeline` returns `TimelineEntry[]` directly (was
  `TimelinePage` with `next_cursor` + `has_more_before`).
- `issueTimelineOptions` is a flat `queryOptions` (was
  `infiniteQueryOptions`); query consumers drop the page-walking dance.
- WS handlers `comment:created` / `activity:created` now `append`
  (oldest-first ASC list) instead of `prepend`. Mirror updater renamed.
- Timeline list view collapses to a single `FlatList data={entries}`,
  no more `pages.flat()` + `fetchNextPage` plumbing.

Mirrors web's post-#2322 `issueTimelineOptions` shape (per
apps/mobile/CLAUDE.md "mirror, don't import").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:51:36 +08:00
Naiyuan Qing
d42922020c chore(mobile): rename ios run scripts to ios:device, add .env.example, document commands
`expo run:ios` always meant device install in this project, but the
unqualified `ios` / `ios:mobile` script names invited confusion with the
simulator default. Rename to `ios:device` / `ios:device:staging` so the
intent is explicit, and pair with a checked-in `.env.example` so a fresh
clone knows which keys mobile needs. CLAUDE.md picks up the new command
list under the existing Commands section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:50:52 +08:00
Naiyuan Qing
c7eb366242 chore(mobile): add Issues entry to More popover
Wires the new workspace Issues page (more/issues.tsx) into GlobalNavMenu,
ordered above Projects (higher-frequency surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:50 +08:00
Naiyuan Qing
69bd280695 feat(mobile): workspace Issues page in More popover
New surface for the workspace-wide issue list. Mirrors web's IssuesPage
(packages/views/issues/components/issues-page.tsx) at mobile fidelity:
SectionList grouped by status, status + priority filter (reuses the
shared IssueFilterSheet), pull-to-refresh, empty/error states, IssueRow
identical to other surfaces.

Differs from My Issues by dropping the Assigned/Created/Agents scope tabs
(workspace-wide list has no per-user scope) and using an independent
view-store so filters don't bleed between the two pages.

Plumbing:
- data/queries/issues.ts → issueListOptions(wsId) using existing
  issueKeys.list(wsId) prefix (already wired into invalidations from
  mutations and project realtime).
- data/stores/issues-view-store.ts → status/priority filter state.
- data/realtime/use-issues-realtime.ts → list-level WS subscription;
  patches list(wsId) on issue:created (prepend) / updated / deleted,
  invalidates on reconnect. Mounted in <RealtimeSubscriptions />.
- data/realtime/issue-ws-updaters.ts → patchIssuesList /
  prependToIssuesList / removeFromIssuesList, plus extending
  patchIssueLabels to also patch list(wsId).
- workspace _layout: register more/issues Stack.Screen, drop Stack.Screen
  entries for the routes deleted in 5cc7f01 (favorites/initiatives/
  views/teams/notifications/pins).

Filters beyond status/priority (assignee/project/label/creator) are a
v1.1 follow-up; v1 ships at My Issues parity for code reuse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:02:31 +08:00
Naiyuan Qing
34a89f20dd refactor(mobile): extract shared IssueRow + props-driven filter sheet
- Add components/issue/issue-row.tsx as the single source for list-style
  issue rendering. `<IssueRow issue showStatus? />` — showStatus opt-in
  for ungrouped lists (project related-issues), default off where the
  SectionList header already shows status (my-issues).
- Replace the two inline IssueRow copies in (tabs)/my-issues.tsx and
  components/project/project-related-issues.tsx.
- Rename MyIssuesFilterSheet → IssueFilterSheet and replace store-coupled
  state with props so the same sheet can serve any view-store. My Issues
  call site passes useMyIssuesViewStore selectors as props.
- Rename filterMyIssues → filterIssues (function was already generic;
  the misnomer just reflected the original single call site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:01:19 +08:00
Naiyuan Qing
5cc7f0178b feat(mobile): redesign More popover — user card + lean nav
- Add user identity card at top of GlobalNavMenu, mirroring web sidebar
  dropdown (packages/views/layout/app-sidebar.tsx:496). Tap pushes into
  the existing settings page where account / workspaces / sign-out
  already live.
- Trim NAV_ITEMS to Projects only. Inbox / My Issues / Chat are bottom
  tabs; Settings is reached via the user card.
- Delete six orphaned stub routes (favorites, initiatives, views, teams,
  notifications, pins) — no remaining external references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:21:45 +08:00
Naiyuan Qing
ad9ba9d790 feat(mobile): add projects feature with realtime cache sync
Mobile parity for the projects domain — browse, detail, create, edit,
delete, plus GitHub resource attach. UX adapted to iOS (Stack push +
modal sheets, picker sheets per property, ActionSheet for Edit/Delete,
collapsible Open/Done buckets in related issues) while preserving web's
semantics: 5 status enums (incl. cancelled), 5 priorities, lead supports
both members and agents, counts come from server fields.

Data layer follows mobile CLAUDE.md rules: parseWithFallback + signal
on every read, optimistic patch + WS event-always-wins on mutations,
mobile-owned ws-updaters (not imported from packages/core) that patch
over invalidate to honour the cellular-data rule. Per-record realtime
hook subscribes to issue:* events filtered by project_id so the
related-issues list stays fresh without pull-to-refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:22:26 +08:00
Naiyuan Qing
8285e90d78 feat(mobile): More tab opens global nav popover
Replaces the full-screen More tab with a bottom-bar trigger that opens a
popover containing the workspace switcher and 9 nav destinations
(Inbox, My Issues, Favorites, Projects, Initiatives, Views, Teams,
Settings, Search). Uses expo-router Tabs.Screen listeners.tabPress +
preventDefault — the more.tsx route is a stub that redirects to inbox
if hit directly. Custom Modal popover (no @gorhom/bottom-sheet) since
that lib still requires Reanimated v3 and mobile is on v4. Account info
+ workspace list + sign out moved into a dedicated Settings page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:02:17 +08:00
Naiyuan Qing
3cb4610304 fix(mobile): simplify create-issue layout, fix render loop
Reshape the new-issue modal into one vertical scrolling form
(title → description → property chips), matching the Apple
Reminders / Linear iOS pattern. Previously the chips sat sticky-
pinned above the keyboard, which made them invisible when the
keyboard was up and stranded at the bottom of an empty screen
when it was down — neither state served the user.

Drop the markdown toolbar and upload buttons from the modal:
mobile users almost never format markdown when creating an issue,
and attachment upload is deferred for this release. Removing them
also lets the form breathe vertically.

Fix the "Maximum update depth exceeded" loop that surfaced once
real data started flowing. Root cause was duplicate
useQuery(projectListOptions) subscribers in CreateFormAttributeRow
and ProjectPickerSheet on the same key, under React 19 strict
mode. Form now holds the full Project object lifted from the
picker, so only the picker queries the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:01:51 +08:00
Naiyuan Qing
3ca160c545 fix(mobile): drop double home-indicator padding under chat composer
chat.tsx wrote SafeAreaView edges={["top","bottom"]} while the parent
<Tabs> container already absorbs the home-indicator inset on behalf of
all tab screens. The result was ~34pt of empty space below the
composer. Sibling tabs (inbox / my-issues / more) all use
edges={["top"]} — chat was the outlier.

The gap only became visible after the floating-card composer landed;
the previous sticky-bar layout disguised it as bg-coloured padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:43:53 +08:00
Naiyuan Qing
c24e086612 fix(mobile): tap message area dismisses keyboard in chat
keyboardShouldPersistTaps="handled" on FlatList has a long-standing
RN bug (facebook/react-native#31448) that prevents the tap-to-dismiss
path from firing in many setups. Wrap ChatMessageList with a Pressable
that calls Keyboard.dismiss() — the canonical workaround documented
in the RN Keyboard guide and the Expo keyboard-handling guide.

Interactive drag-dismiss on the FlatList itself (the previous commit)
is an independent code path and continues to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:38:30 +08:00
Naiyuan Qing
fdc9f916f2 feat(mobile): iMessage-style keyboard dismiss in chat message list
Drag the list to interactively pull the keyboard down with the finger,
or tap empty space between bubbles to dismiss. `handled` keeps long-
press action sheets and other in-bubble Pressables firing normally.

Sending a message intentionally keeps the input focused so the user
can immediately type the next one — RN's default and the chat-app
standard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:25:24 +08:00
Naiyuan Qing
d2cee9741c fix(mobile): stable empty reference in viewed-issues selector
selectViewedIssueIds was returning a fresh `[]` when the workspace had
no entry yet, which made useSyncExternalStore see a different snapshot
on every read and trigger "getSnapshot should be cached" + infinite
re-render. Share a single frozen empty array for all no-entry paths,
matching the Zustand footgun rule in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:19:43 +08:00
Naiyuan Qing
6067a7a0d6 feat(mobile): @ in chat picks an issue (Recent + My issues)
In 1:1 user↔agent chat sessions, @member and @agent are noise (no
notification channel; the session is already bound to one agent).
Switch the mention bar to surface issues instead — Recent (most recent
5 from the in-memory viewed-issues store) followed by My issues
(assigned-to-me, max 10, deduped). The serialized token matches web
byte-for-byte ([MUL-XXX](mention://issue/<uuid>)) so the agent can read
the reference directly even though chat.go SendChatMessage doesn't yet
run ParseMentions — that's a follow-up.

MentionSuggestionBar gains a mode="comment"|"chat" prop; comment mode
is the default and preserves existing behaviour for the issue comment
composer and new-issue body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:51:10 +08:00
Naiyuan Qing
ee1c040071 feat(mobile): add issue MentionType + viewed-issues store
Extend MentionType with "issue" and serialize issue mentions without
the leading `@` in the link label, matching web's
mention-extension.ts:67-74. New in-memory LRU tracks recently viewed
issues per workspace so the chat composer can surface them next.

Issue detail screen pushes its id into the store on mount. Suggestion
bar UI lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:48:32 +08:00
Naiyuan Qing
2ac2609809 feat(mobile): redesign chat composer as floating card
Move chat input to a rounded card with inline @ and Send/Stop buttons
(Linear / iMessage idiom), dropping the markdown toolbar that comment-
composer needs but chat doesn't. Send stays visible-but-disabled when
there's no draft so the button row no longer jitters as the user types.
Adds SF Symbols, expo-haptics, and reanimated crossfade for send↔stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:34:23 +08:00
Naiyuan Qing
da1c7d5bc2 feat(mobile): add due_date / project to create-issue, drop agent toggle
Wire the last two CreateIssueRequest fields that have a meaningful UX on
mobile (due_date, project_id) to the new-issue form via two new chips
sharing the existing CreateFormAttributeRow + picker-sheet pattern.

Fixes a silent 400 on the existing detail-page due_date update: the
picker was emitting YYYY-MM-DD but server/internal/handler/issue.go
parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now
sends full ISO, matching web's due-date-picker.tsx.

Removes the placeholder agent-mode toggle from new-issue — it was a
dead UI surface (logged to console on submit, never wired). Mobile's
create-issue is now manual-only, aligned with web's form semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:21:48 +08:00
Naiyuan Qing
3a439d97a1 feat(mobile): chat v1 — single-tab IA, optimistic send, two-tier WS
Fill the Chat tab placeholder. UX is mobile-native (top bar with tap-title
sheet, message list, bottom composer — no two-layer nav); logic is at
parity with web (API/events/has_unread/optimistic sequence/permissions/
enums all mirrored).

Includes:
- data layer: 8 chat API methods + zod schemas with .catch() enum drift
  fallback; queries / mutations (optimistic delete + markRead); per-
  session drafts store
- two-tier realtime: listing-level hook mounted in workspace _layout
  (chat:session_* + chat:done for has_unread), per-record hook mounted in
  the chat screen (chat:message/done + 5 task:* events, all filtered by
  chat_session_id, scoped reconnect invalidates); ws-updaters carry an
  invalidate fallback for pre-#2123 servers that omit chat:done payload
- rule mirrors: canAssignAgent, failureReasonLabel, agent availability
  three-state hook (mirror-not-import per apps/mobile/CLAUDE.md)
- UI: ChatHeader (tap title → SessionSheet) + ChatMessageList (FlatList,
  destructive bubble on failure_reason) + ChatComposer (mention +
  markdown toolbar minus file/image) + StatusPill (Thinking · Ns) +
  SessionSheet (with agent avatars + long-press delete) +
  AgentPickerSheet + NoAgentBanner

v1 cuts (deferred to v2): file upload, rename, Chat tab unread badge,
agent presence dot, task tool_use detail expansion, focus mode route
anchor, starter prompts, history pagination, mobile test infra.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:47 +08:00
Naiyuan Qing
a5c5b955df chore(mobile): merge main into feat/mobile-ios
Resolve pnpm-lock.yaml by regenerating from main + branch package.json.
2026-05-14 09:25:55 +08:00
Naiyuan Qing
0406ae3c45 fix(mobile): hard 30s fetch timeout + TanStack Query signal pass-through
Triggered by a real user-visible bug: the Inbox tab's pull-to-refresh
spinner sometimes stuck on indefinitely after returning the app to the
foreground. List items rendered normally underneath, but `isRefetching`
never flipped back to `false`.

Root cause: api.ts fetch() had no timeout, no AbortController, and
ignored caller-supplied signals. iOS suspends background apps and can
silently kill in-flight network tasks (facebook/react-native#35384,
#38711). When the app foregrounded, the suspended Promise neither
resolved nor rejected. TanStack Query saw a fetch already in flight
and would not start a replacement on invalidate — it just waited
forever on the dead Promise.

Fix is three layers (all three required — partial fix leaves a footgun):

1. api.ts fetch() — hard 30s timeout via manual AbortController +
   setTimeout. Hermes does not implement AbortSignal.timeout() /
   AbortSignal.any() (facebook/react-native#42042, livekit#4014), so
   composition is via addEventListener("abort", ...) forwarding. On
   timeout we throw an ApiError(message, status=0) so callers see a
   real error instead of a Promise-that-never-settles.

2. All read-side api methods now accept opts?: { signal?: AbortSignal }
   and forward to fetch(): listInbox, listWorkspaces, getMe, listMembers,
   listAgents, listIssues, getIssue, listTimeline, listLabels,
   listProjects. Mutations are unchanged — TanStack Query doesn't pass
   a signal to mutationFn.

3. All queryFn definitions in data/queries/* now destructure { signal }
   and forward it. The TanStack official cancellation guide states that
   the signal is aborted when a query becomes out-of-date or inactive,
   so this is the primary mechanism that unwedges stuck queries (the
   30s timeout is the safety net for cases where nothing else fires).

Already in place (untouched, but documented):
- query-client.ts wires focusManager ← AppState and onlineManager ←
  NetInfo per TanStack's React Native official guide. focusManager
  alone wasn't enough — when a fetch hangs, "focused = true" can't
  unstick the query without signal cancellation or timeout. The three
  pieces work together.

Docs (apps/mobile/CLAUDE.md):

New Lesson #5 captures all of the above with:
- The original symptom + root cause
- The three-part rule (timeout / api opts / queryFn destructure)
- Hermes-specific caveats with citations to the upstream issues
- A grep verification command future readers can run to enforce part 3

Verified:
- pnpm --filter @multica/mobile typecheck passes
- pnpm --filter @multica/mobile lint shows only pre-existing issues
  unrelated to this change
- grep -n "queryFn: () =>" apps/mobile/data/queries/*.ts returns zero
  matches (every queryFn destructures signal)

Sources cited in CLAUDE.md:
- TanStack Query Cancellation guide (tanstack.com/query/v5)
- TanStack Query React Native official guide (tanstack.com/query/v5)
- facebook/react-native#42042 (AbortSignal.timeout unavailable in Hermes)
- facebook/react-native#35384 (iOS background fetch failure)
- facebook/react-native#38711 (iOS background JS Timers don't fire)
- livekit/livekit#4014 (AbortSignal.any unavailable in React Native)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:05 +08:00
Naiyuan Qing
9718768a4f feat(mobile): unified input UX — mention hook, markdown toolbar, file upload
new-issue Description and Comment composer used to each carry their
own copy of mention state (mentioning / recomputeMentioning /
onChangeText / onSelectionChange / onAtButton / onSelectMention /
serialize), ~50 LOC of identical boilerplate per surface. The
description had no toolbar at all; the comment had a lone left-side
`@` button. Visually the two body inputs looked like different
products — description was bare text, comment was rounded-2xl
bg-secondary with a focus tint.

Three changes consolidate the body-input experience:

1. Shared mention pipeline. `useMentionInput()` in lib/use-mention-input.ts
   owns text / selection / markers / mentioning, plus handlers
   (onChangeText, onSelectionChange, onAtButtonPress), suggestion-bar
   props, `insertAtCursor`, `insertAtLineStart`, serialize, snapshot,
   restore, reset. Comment-composer and new-issue both consume it,
   killing the duplication.

2. Shared keyboard-bar markdown toolbar. Linear-iOS range: `@`, bullet
   list, checklist, code block, quote, image, file. All buttons are
   literal-character inserts via hook helpers — no WYSIWYG. Toggles
   like bold/italic are deliberately out of scope because RN TextInput
   can't render styled ranges inside the input; a real WYSIWYG would
   mean swapping to react-native-enriched and crossing an HTML <->
   markdown boundary, which is a separate decision.

3. File upload. `api.uploadFile(asset, { issueId?, commentId? })`
   mirrors web's `/api/upload-file` contract but takes the RN-shaped
   `{ uri, name, type }` payload and validates the response against
   a strict `AttachmentSchema` (no silent fallback — an empty `url`
   would put a broken link into the editor). `useFileAttach()` glues
   expo-image-picker / expo-document-picker into the toolbar's image
   and file buttons. Context follows web: comments pass issueId,
   not-yet-created issues pass nothing. MAX_FILE_SIZE is mirrored, not
   imported, per mobile CLAUDE.md.

Cleanup:
- `MOBILE_PLACEHOLDER_COLOR` + `MIN_BODY_INPUT_HEIGHT_PX` in
  components/ui/input-tokens.ts; six hardcoded `#a1a1aa` callers now
  reference the const.
- Description now sits in a rounded-2xl bg-secondary/40 container
  with a focus-tint border, visually matching the comment composer.
- app.config.ts gets `expo-image-picker` plugin with
  `photosPermission` set and `cameraPermission` / `microphonePermission`
  disabled — without this Info.plist string, calling the image picker
  hard-crashes on iOS 14+.

A dev-client rebuild is required (new native modules); existing
behaviour and read-only rendering are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:21:11 +08:00
Naiyuan Qing
230e939e53 fix(mobile): markdown segment spacing uses Yoga gap, not per-child margin
Two consecutive fenced code blocks (and code-image / image-image
combos) rendered with effectively zero gap on iOS — NativeWind 4
compiles `my-3` to `marginVertical: 12`, but Yoga's sibling margin
behaviour doesn't accumulate the way web CSS does. Result: a `my-3`
sibling pair landed at ~12px on the screen instead of 24px, and the
border-on-border made it look like the two blocks were glued.

Move the spacing from per-child `marginVertical` to a `gap-3` on the
markdown root `<View>`. Gap is layout-level (Yoga implements it
directly), independent of margin behaviour, and uniformly applies
between every segment pair — prose ↔ code, code ↔ code, image ↔ code,
etc. CodeBlock and MarkdownImage drop their `my-3` / `mb-3` since the
parent now owns the spacing.

Prose ↔ code reads as ~24px (prose's enriched-markdown
`paragraph.marginBottom` 12 + root gap 12), which is the comfortable
"new block" feel; code ↔ code reads as exactly 12px, which is the
"these are related" feel. Both improve on the previous 0–8px crunch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:20:46 +08:00
Naiyuan Qing
bc4594b091 feat(mobile): WS realtime coverage — issue detail / my issues / agent tasks
Previous iteration shipped issue creation but mobile only ran WS for
inbox. Anything else (issue detail, my-issues list, agent task progress)
was pull-refresh only. Cross-client edits, agents working in the
background, and concurrent user changes all required the user to
manually refresh.

This commit closes that gap so all four user-facing surfaces stay
live without input. Mobile now matches web/desktop in product
freshness, while keeping mobile-specific patterns (patch over
invalidate, per-screen mount, event-always-wins) that reflect cellular
and AppState constraints.

New (3 files):

- data/realtime/issue-ws-updaters.ts — mobile-owned cache patchers.
  Pure functions over QueryClient: patchIssueDetail, prependTimelineEntry,
  patchTimelineEntry, removeTimelineEntry, patchMyIssuesList,
  removeFromMyIssuesList, addCommentReaction, removeCommentReaction,
  addIssueReaction, removeIssueReaction, patchIssueLabels,
  commentToTimelineEntry. NOT imported from packages/core because web's
  updaters bind to web's issueKeys instance and target bucketed caches
  mobile doesn't have — see CLAUDE.md "Mobile-owned updaters" rule.

- data/realtime/use-issue-realtime.ts — per-issue subscriptions mounted
  by the detail screen. Subscribes to 11 issue/comment/activity/reaction
  events plus 6 task:* events for live agent progress. Every handler
  filters by issue_id so we ignore noise from other issues. Reconnect
  invalidates only this issue's detail + timeline (not a global sweep).
  On issue:deleted for the active id, runs onDeleted callback so the
  screen can router.back() rather than strand the user on a 404.

- data/realtime/use-my-issues-realtime.ts — listing-level subscriptions
  mounted globally. issue:created → invalidate myAll (we don't know
  scope/filter membership for a fresh issue). issue:updated → patch via
  setQueriesData across every cached scope/filter combination.
  issue:deleted → strip from every cached list. Reconnect → invalidate
  myAll.

Modified (2 files):

- app/(app)/[workspace]/_layout.tsx — RealtimeSubscriptions adds
  useMyIssuesRealtime alongside useInboxRealtime. Both are workspace-
  session lifetime.

- app/(app)/[workspace]/issue/[id].tsx — mounts useIssueRealtime(id)
  with router.back as the onDeleted callback.

Docs (apps/mobile/CLAUDE.md):

New top-level section "## Realtime / WebSocket strategy" before the
Lessons section. Documents:
- Three-layer stack (ws-client → realtime-provider → per-feature hooks)
- Mount strategy: list-level global vs per-record per-screen, and why
  mobile doesn't use a single centralized useRealtimeSync like web
- Patch over invalidate (cellular-data rule)
- Mobile-owned updaters (don't import packages/core/issues/ws-updaters)
- Event-always-wins conflict policy
- Per-hook reconnect scoping (no global invalidate sweep)
- Recipe for adding new event coverage

Out of scope (deferred):
- Workspace member events (Phase 3D) — wait until More tab adds a real
  members list
- "N new comments" floating banner — patch-only for now
- Push notifications (APNs) — requires server config + entitlement

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:47:57 +08:00
Naiyuan Qing
ab1fe4fa1f feat(mobile): new issue creation — Manual mode fully wired with @ mention
Mobile can now actually create issues. Phase 1 left submit as a
console.log stub; this iteration wires Manual mode end-to-end so an
issue typed on a phone lands in the backend and appears in the user's
my-issues list on next refresh.

Wire-up:
- api.createIssue(body) — POST /api/issues, mirroring server route at
  server/cmd/server/router.go:320. Matches the CreateIssueRequest type
  exported from @multica/core/types so payload shape agrees across
  clients.
- useCreateIssue() mutation in data/mutations/issues.ts — no optimistic
  insert (the my-issues list is status-bucketed + scope-filtered, so
  optimism needs bucket+scope decisions; invalidation is simpler and
  hosted-backend latency is sub-300ms). onSuccess invalidates myAll
  and inbox query keys.
- new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on
  success, surfaces errors via Alert.alert with the form state preserved
  so the user can retry. Button shows a spinner during the in-flight
  request and all inputs are disabled.

@ mention in description (members + agents):
- Mirrors comment-composer.tsx pattern exactly — selection tracking,
  tokenAtCursor on every change/selection event, MentionSuggestionBar
  rendered above the chip row, insertMention on pick, markers list
  appended.
- Title input stays plain (web doesn't allow mentions in title; we
  mirror that).
- Wire format on submit: serializeMentions(description, markers) →
  `[@name](mention://type/id)` markdown. Recognised by:
    * server/internal/util/mention.go ParseMentions
    * packages/views/editor/extensions/mention-extension.ts (web Tiptap)
    * apps/mobile/components/issue/mention-chip.tsx (mobile timeline)
- Backend does NOT trigger inbox notifications for mentions in issue
  descriptions (only on comments — see server/internal/handler/comment.go
  ParseMentions call). Mobile doesn't need to send a separate mentioned_*
  field; the markdown alone is sufficient.

Header polish:
- SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator
  in place of the ↑ glyph while pending. Defends against double-tap.
- ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle
  to match the new-issue / search modal visual reference (Linear-style).

Agent mode unchanged — still a placeholder that console.logs and
dismisses. Phase 3 will wire the real agent picker, apiClient
.quickCreateIssue, and the daemon version gate.

Explicitly NOT in this commit (later phases):
- Markdown formatting toolbar (Phase 2C)
- Project / Labels / Due date / Parent chips (Phase 2D)
- Image / file attachments (Phase 2E)
- #MUL-42 issue references, @all mention
- Draft persistence, "Create Another" toggle
- Pre-fill from sub-issue entry, optimistic list insert
- Success toast (success path = silent dismiss; mobile has no toast
  component yet)

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:33:26 +08:00
Naiyuan Qing
d23d786724 feat(mobile): hybrid markdown — Shiki code + lightbox images, prose via enriched
react-native-enriched-markdown does not expose JS-level custom renderers
(issues #54, #232, #246), so syntax highlighting, tap-to-lightbox, and
copy buttons cannot live inside enriched. Maintainer-endorsed workaround
(#246): split markdown at those boundaries and render the leaves in
React.

splitMarkdown walks marked.lexer tokens and emits prose / code / image
segments. Each prose island gets its own EnrichedMarkdownText; code
blocks reuse the in-house CodeBlock (Shiki + copy + horizontal scroll);
images reuse MarkdownImage (expo-image + lightbox). Paragraph-embedded
images are promoted to block siblings, matching GitHub mobile and
Linear iOS.

Drops ~600 LOC of dead walker code (render-block, render-inline, ast,
link, mention-chip, key) that the previous engine swap left behind.

Visual polish for the hybrid output:
- inline code alpha 20% → 12%; enriched paints over the full line
  height and RN can't apply the padding/radius/0.85em that keep
  GitHub web's chip compact, so the web alpha reads too heavy here.
- new `code-surface` token (#e8e8eb), one step darker than `secondary`,
  plus a 1px `border-border` hairline. Code block now elevates inside
  both white issue bodies and grey comment cards.
- code block margin my-3 — breathing room both sides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:27:50 +08:00
Naiyuan Qing
6239f2c1f5 feat(mobile): 4-tab shell — Chat tab, More tab, single-row header, filter chips, modal stubs
Scaffolds the next phase of mobile so per-feature work has a clean shell
to fill into. No new business logic, no data fetching beyond what already
existed; this is layout + navigation only.

Tab restructure (3 → 4 tabs):
- Add Chat tab placeholder (will port web bottom-right chat widget logic).
- Rename Settings → More; convert to grouped iOS-style list with sections
  Workspace / Personal / Account / Workspaces, all SectionGroup + NavRow.
- Workspace switcher list inside More uses the same NavRow visual pattern
  (active row marks with checkmark, inactive shows chevron).

Header (single-row):
- ScreenHeader simplified to one row: large title left, right actions
  slot. Removed the second-row WS switcher idea — switcher only lives in
  More now (the global header would mix scope levels with global actions).
- New HeaderActions component holds the two global actions: search and
  create-issue. Wired into all 4 tabs.

My Issues filter relocation:
- Filter button moved out of the header right slot (was a scope-mismatch
  hazard — global header should not host tab-local controls). Now sits
  inline at the right end of the ScopeTabs row.
- New ActiveFilterChips row renders below ScopeTabs when filters are
  active; each chip is tap-to-clear. Mirrors iOS Mail/Things UX.

Stubs for next phase:
- [workspace]/new-issue.tsx and [workspace]/search.tsx as modal screens
  presented from HeaderActions. Both have a Cancel button (new
  ModalCloseButton) in headerLeft.
- More tab sub-pages: more/{projects,agents,pins,notifications}.tsx
  registered in [workspace]/_layout.tsx with native Stack headers.

Cross-cutting:
- lib/issue-status.ts exports PRIORITY_LABEL alongside STATUS_LABEL
  (used by the new filter chip row).
- All new code uses Ionicons from @expo/vector-icons; not adding
  lucide-react-native — see comment-composer.tsx for the reasoning.

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change; more/ subdirectory
checked against .gitignore per CLAUDE.md mobile rule 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:45:42 +08:00
Naiyuan Qing
90fd3ff86c wip(mobile): markdown engine swap to enriched-markdown + sprint progress
Bundles the markdown rendering overhaul plus in-flight mobile feature
work as a single WIP for review.

Markdown work (the new direction):
- Swap internal Markdown component from hand-rolled marked walker to
  react-native-enriched-markdown (Software Mansion, native md4c).
  Public API <Markdown content={...} /> unchanged; consumers untouched.
  Mention links degrade to colored links + onLinkPress routing.
- Pre-swap fixes that landed first: 3-layer inline code (later corrected),
  Shiki via react-native-shiki-engine wired (now bypassed; code retained
  for selective re-enable on code blocks), code block copy button with
  expo-clipboard + expo-haptics, inline SVG copy/check icons, header
  scale calibrated to Apple HIG, paragraph leading-6 for CJK, list
  bullet column 24->16, lineBreakStrategyIOS="hangul-word" on outer
  paragraph Text.
- Preprocess: <br> -> "  \n" (CommonMark HardBreak) so md4c respects
  intentional breaks without misreading bare \n.
- Drop the Expo Go compatibility constraint from CLAUDE.md and
  markdown-renderer-research.md (project runs on dev client).
- New apps/mobile/docs/markdown-renderer-research.md captures the
  RN nested-Text rendering constraints (#10775 / #45925 / #6728), the
  CJK amplification mechanism, the typography scale calibration, and
  every decision-log entry from the engine evolution.

Other in-flight mobile features included:
- Issue detail timeline polish, comment composer + action sheet,
  mention suggestion bar, emoji picker sheet, reaction bar.
- Status / priority / assignee / label / due date picker sheets.
- My Issues filter sheet + view store.
- Realtime layer (ws-client, realtime-provider, use-inbox-realtime).
- Data layer additions (queries, mutations, schemas, attribute chips).

Cross-package:
- packages/core/api/schemas.ts: export IssueSchema for mobile use.

Build: native rebuild required after pulling (enriched-markdown is
a native Fabric module).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:07:04 +08:00
Naiyuan Qing
48c1e3746e feat(mobile): standalone markdown renderer with mentions, files, images, lightbox
Replaces `<Text>{content}</Text>` placeholders in issue description and
comment body with a full markdown pipeline at apps/mobile/lib/markdown/.

Pipeline: preprocess → marked.lexer → AST transforms → RN component tree.
Uses `marked` (~30KB JS parser) for CommonMark+GFM tokens; renderer is
hand-written (~600 LoC) for full control over RN's text-in-text rules,
mention chips, file cards, and inline-image-to-block promotion.

Supported in this drop:
- Headings, paragraphs, lists (ordered/unordered/task), block quotes,
  hr, fenced code (no syntax highlight), strong/em/del/codespan, autolinks
- Mention chips: mention://member/<id>, mention://agent/<id>,
  mention://issue/<id> — name resolution via existing useActorLookup;
  issue tap navigates to /:slug/issue/:id
- File cards: !file[name](url) preprocessed to [📎 name](url) link;
  Linking.openURL hands off to system viewers (PDF, doc, share sheet)
- Inline images promoted to block siblings (AST pass) — marked always
  wraps `![]()` in paragraph and RN can't put Image inside Text
- Real aspect ratio via Image.getSize, expo-image for caching/transition,
  global LightboxProvider with react-native-image-viewing for tap-to-zoom
- Tables degrade to card-per-row with header:value pairs (mobile-friendly
  responsive pattern; horizontal scroll tables get lost on touch)
- Embedded HTML stripped before lexing: <br> → newline, comments removed,
  other tags peeled to inner text. Residual html tokens render muted

Cross-package: lifted preprocessMentionShortcodes to @multica/core/markdown
so mobile can import it (mobile may import pure functions from core; cannot
import from packages/ui per Sharing Principles). packages/ui/markdown
keeps its own synced copy with a cross-reference comment — packages/ui
cannot import from core (Package Boundary Rules), so two synced copies
is the cleanest path.

Drops the comment-card "📎 N attachments" placeholder; markdown rendering
covers inline images and !file[] cards. attachments[] is backend cleanup
metadata, not display content (matches web).

New deps: marked@18, expo-image@55, react-native-image-viewing@0.2.
All Expo Go compatible — no native modules added.

Plan: ~/.claude/plans/plan-dynamic-narwhal.md
Research: apps/mobile/docs/markdown-renderer-research.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:02:14 +08:00
Naiyuan Qing
bb281e7dbd feat(mobile): activity row parity with web — lead icon, coalesce badge, single-line
Activity rows previously showed a two-line `[verb] / [absolute time]` block
with no icons, mismatching web (issue-detail.tsx:1046-1100). This redesign
brings mobile in line:

- Single-line layout: [lead icon] [name] [verb...truncate] [×N] [time→]
- Contextual lead icon: StatusIcon(details.to) for status_changed,
  PriorityIcon(details.to) for priority_changed, inline Calendar SVG for
  due_date_changed, ActorAvatar(size=16) otherwise
- Relative time right-aligned (drops the made-up "Linear-style" absolute
  timestamp; web uses relative + hover tooltip, mobile keeps relative only
  for v1)
- Coalesce ×N badge for non-task actions; task_completed/failed already
  bake the count into their copy
- Whole row text-xs muted-foreground — activity is supposed to feel quiet
  next to comment bubbles
- FlatList contentContainer gap-3 owns row spacing; rows themselves drop
  their own py so spacing doesn't double up

Calendar icon is an inline 16-line react-native-svg primitive — avoids
adding lucide-react-native to the mobile baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:11:30 +08:00
Naiyuan Qing
af79a7fb4e feat(mobile): ApiClient capability set + issue detail v1 + lessons in CLAUDE.md
ApiClient hardening (data/api.ts):
- onUnauthorized callback wired in _layout.tsx — 401 clears token,
  workspace store, TanStack Query cache, replaces nav to /login.
  Idempotent via signingOutRef. Mirrors packages/core/api/client.ts
  handleUnauthorized.
- X-Request-ID per request (lib/request-id.ts)
- Structured logger: `[api] -> METHOD path (rid)` on start, `[api] <-
  STATUS path (rid, duration)` on end. console.error for 5xx,
  console.warn for 404, console.log for success.
- Zod parseWithFallback for listIssues + listTimeline (the only two
  endpoints with schemas in packages/core/api/schemas.ts today —
  matches web's current coverage; new schemas should land on the web
  side first and both clients pick them up).

Core export (packages/core/package.json):
- Add `./api/schemas` to exports map so mobile can import the shared
  Zod schemas + EMPTY_* fallbacks (pure data, on the mobile sharing
  whitelist per CLAUDE.md).

Issue detail v1 (app/(app)/[workspace]/issue/[id].tsx):
- Read issue + infinite-scroll timeline + comment composer
- Stack header shows MUL-XXX once detail loads
- Supporting files: data/queries/issues.ts, data/mutations/issues.ts,
  components/issue/{timeline-list,comment-composer,...},
  lib/{format-activity,timeline-coalesce,timeline-thread}.ts
- Property edits, reactions, mentions, image lightbox deferred to V2+

apps/mobile/CLAUDE.md — Lessons learned (encode into reflexes):
1. Install/upgrade deps: `pnpm view <pkg> dist-tags` first; `expo
   install` for Expo packages, never `pnpm add` blindly
2. New source subdirectory: `git check-ignore -v` to verify against
   root .gitignore generic rules (data/, build/, bin/); add !data/
   override if matched. Cost a 14-file missing commit before.
3. ApiClient capability list (Zod parse / 401 callback / X-Request-ID
   / structured logger) — all baseline, not polish
4. Visual alignment is baseline, not polish — tab icons, screen titles,
   right-column vertical alignment of trailing elements, type-aware
   secondary lines (mirror InboxDetailLabel, not raw item.body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:05:00 +08:00
Naiyuan Qing
a979124c21 fix(mobile): unignore data/ + dedup, layout, mark-read, SVG icons, issue page
Critical: previous commit (def9c08d) was missing apps/mobile/data/ entirely
because root .gitignore has a generic `data/` rule (for backend runtime
dirs) that swallowed mobile's source tree. Added !data/ override to
apps/mobile/.gitignore. The branch was running locally only because
untracked files still load at runtime.

Functional changes on top:

- Status icon: react-native-svg, 7 variants (backlog 16-dot ring / todo /
  in_progress 0.5 / in_review 0.75 / done + check / blocked + slash /
  cancelled + x). Geometry mirrors packages/views/issues/components/
  status-icon.tsx (14x14 viewBox, OUTER_R=6, FILL_R=3.5)
- Priority icon: 4 ascending bars + "none" horizontal dash; mirrors web
  priority-icon.tsx. Urgent pulse animation deferred.
- Inbox row click: optimistic mark-read (mirrors packages/core/inbox/
  mutations.ts useMarkInboxRead) + router.push to /[ws]/issue/[id]
- My Issues row click: router.push to /[ws]/issue/[id]
- /[ws]/issue/[id] placeholder with native iOS Stack header + back
  button + edge-swipe-to-dismiss
- Inbox layout: title-row right edge = StatusIcon, body-row right edge
  = timeAgo, vertically aligned (matches web inbox-list-item.tsx)
- InboxDetailLabel mobile mirror at components/inbox/detail-label.tsx —
  type-aware second-line ("Set status to (icon) Done" / "Mentioned" /
  "Assigned to <name>" etc.). Was rendering raw markdown body which
  leaked ## heading prefixes.
- Inbox dedup: deduplicateInboxItems mirrored into apps/mobile/lib/
  inbox-display.ts (filter archived -> group by issue_id -> keep newest
  -> sort desc). Without it mobile rendered 3 unread dots while web
  sidebar showed "Inbox 1". Documented in apps/mobile/CLAUDE.md
  "Behavioral parity" with the lesson: before rendering ANY list-shaped
  API response, mirror every preprocessing step web/desktop runs
  between useQuery and JSX (dedupe / coalesce / filter / display
  helpers). Backend returns raw cache shape; client shapes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:38:25 +08:00
Naiyuan Qing
def9c08d35 feat(mobile): v1 shell — auth, workspace switching, inbox + my-issues
- Auth: email OTP login mirroring packages/core/auth/store.ts behavior
  (401 clears token, non-401 preserves; token written only on verify
  success); expo-secure-store with key "multica_token" matching desktop
- Workspace context: /[workspace]/ URL slug as source of truth (deep-
  link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore
  persists last-selected slug for cold-start restore
- Bottom tabs (Ionicons): Inbox / My Issues / Settings
- Inbox: actor avatar, unread brand-dot, status icon, time-ago + body
  subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/
  components/inbox-display.ts
- My Issues: priority bars (matching IssuePriority bar counts from
  packages/core/issues/config/priority.ts), status dot, identifier,
  title, assignee avatar
- Settings: account info + workspace switcher; switching replaces nav
  to /[newSlug]/inbox so back stack doesn't trail to old workspace
- Multi-env: .env.staging / .env.production / .env.development.local
  with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps
  bundleIdentifier so dev/staging/prod coexist on a device
- Build: dev:mobile + dev:mobile:staging scripts; main turbo
  build/typecheck/lint/test filter excludes @multica/mobile

Tech-stack (locked in apps/mobile/CLAUDE.md):
- Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog)
- NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4;
  visual tokens transcribed by hand from packages/ui/styles/tokens.css)
- TanStack Query 5 with AppState focus listener; Zustand 5

Not in this commit (intentional): issue detail page, mark-read mutation,
pull-to-refresh polish — next iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:14:38 +08:00
Naiyuan Qing
518d342021 docs(mobile): establish independence rules and tech-stack baseline
- Refactor root CLAUDE.md sharing rules into a single Sharing Principles
  section, replacing scattered mentions across 10 places with one source
  of truth + minimal "(web + desktop)" qualifiers on existing sections
- Add apps/mobile/CLAUDE.md with locked tech-stack baseline: Expo SDK 54,
  React Native 0.81, NativeWind 4 + Tailwind 3.4, react-native-reusables,
  TanStack Query 5, Zustand, expo-secure-store
- Mobile pins React directly (does NOT track root catalog:) so the Expo
  SDK / RN release schedule isn't blocked by web/desktop upgrades
- Visual tokens are mobile-owned (transcribed from packages/ui/styles/
  tokens.css by hand, not imported); Tailwind v3.4 vs v4 mismatch makes
  file sharing impractical anyway
- Document mobile build/release pipeline (main CI excludes mobile,
  separate mobile-verify and mobile-release workflows, EAS Update for OTA)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:23:31 +08:00
976 changed files with 7127 additions and 91565 deletions

View File

@@ -21,31 +21,21 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers and to show correct daemon setup commands in the web UI. Leave
# unset behind a same-origin reverse proxy or for plain localhost dev —
# the frontend will compose the URL from window.origin + webhook_path in
# that case. Headers are intentionally not used to derive this value, to
# avoid Host / X-Forwarded-Host spoofing when a self-hosted reverse proxy
# is not hardened.
# triggers. Leave unset behind a same-origin reverse proxy or for plain
# localhost dev — the frontend will compose the URL from
# window.origin + webhook_path in that case. Headers are intentionally
# not used to derive this value, to avoid Host / X-Forwarded-Host
# spoofing when a self-hosted reverse proxy is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
@@ -89,18 +79,11 @@ RESEND_FROM_EMAIL=noreply@multica.ai
# Takes priority over Resend when SMTP_HOST is set.
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
# SMTP_TLS controls the TLS mode:
# - unset / "starttls" (default): plaintext connect, upgrade via STARTTLS.
# - "implicit" (aliases: "smtps", "ssl"): TLS handshake on connect (SMTPS).
# Required by providers that only offer port 465 and do not advertise
# STARTTLS (e.g. Aliyun enterprise mail). Auto-enabled when SMTP_PORT=465
# and SMTP_TLS is unset.
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
SMTP_TLS=
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
@@ -108,9 +91,7 @@ SMTP_TLS=
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -140,9 +121,7 @@ COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
@@ -191,11 +170,9 @@ GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
@@ -216,14 +193,6 @@ ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
# Set to "true" to disable workspace creation for every caller on this
# instance (#3433). Operators usually leave this unset, bootstrap the
# shared workspace, then flip this to "true" and restart so subsequent
# users join only via invitations and the entire deployment is visible to
# the platform admin. The web UI reads this from /api/config at runtime,
# so toggling requires a backend restart but not a frontend rebuild.
DISABLE_WORKSPACE_CREATION=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server

View File

@@ -29,9 +29,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
@@ -42,12 +39,7 @@ jobs:
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/types/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest

View File

@@ -1,65 +0,0 @@
name: Mobile Verify
# Runs lint + typecheck for apps/mobile only, parallel to the main CI
# workflow. Path-filtered so PRs that don't touch mobile (or anything
# mobile transitively depends on) pay zero cost.
#
# Path scope rationale — mobile transitively depends on:
# - apps/mobile/** — the app itself
# - packages/core/** — mobile imports types AND pure functions
# (agents, markdown, permissions, api/schemas, …),
# not only @multica/core/types
# - package.json / pnpm-lock.yaml — install graph
# - pnpm-workspace.yaml — catalog versions
# - turbo.json — turbo task pipeline
#
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
# task list below.
on:
push:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
pull_request:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
mobile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Type check and lint
run: pnpm exec turbo typecheck lint --filter=@multica/mobile

View File

@@ -322,47 +322,6 @@ jobs:
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
helm-chart:
needs: [verify, docker-backend-merge, docker-web-merge]
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
concurrency:
group: release-helm-chart-${{ github.ref }}
cancel-in-progress: true
env:
CHART_DIR: deploy/helm/multica
OCI_REGISTRY: oci://ghcr.io/${{ github.repository_owner }}/charts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Sync chart metadata with release tag
env:
TAG_NAME: ${{ needs.verify.outputs.tag_name }}
run: |
chart_version="${TAG_NAME#v}"
sed -i -E "s/^version:.*/version: ${chart_version}/" "$CHART_DIR/Chart.yaml"
sed -i -E "s/^appVersion:.*/appVersion: \"${TAG_NAME}\"/" "$CHART_DIR/Chart.yaml"
echo "CHART_VERSION=${chart_version}" >> "$GITHUB_ENV"
- name: Lint chart
run: helm lint "$CHART_DIR"
- name: Package chart
run: helm package "$CHART_DIR" --destination .chart-packages
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Push chart to GHCR
run: helm push ".chart-packages/multica-${CHART_VERSION}.tgz" "$OCI_REGISTRY"
- name: Verify published chart
run: helm show chart "$OCI_REGISTRY/multica" --version "$CHART_VERSION"
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it

2
.gitignore vendored
View File

@@ -48,8 +48,6 @@ apps/web/test-results/
# context (agent workspace)
.context
.agent_context/
.multica/
# local settings
.claude/

View File

@@ -203,14 +203,6 @@ Every Go handler in `server/internal/handler/` follows these rules. The conventi
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### Dependency Declaration Rule
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:

View File

@@ -655,7 +655,7 @@ multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` accepts `create_issue` (creates a new issue on each run and assigns it to the agent) or `run_only` (enqueues a direct agent task without creating an issue). `--agent` accepts either a name or UUID.
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger

View File

@@ -8,8 +8,6 @@ WORKDIR /app
# Copy workspace config and all package.json files for dependency resolution
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
COPY apps/web/package.json apps/web/
# postinstall runs fumadocs-mdx which reads apps/web/source.config.ts
COPY apps/web/source.config.ts apps/web/source.config.ts
COPY packages/core/package.json packages/core/
COPY packages/ui/package.json packages/ui/
COPY packages/views/package.json packages/views/

View File

@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
PORT ?= 8080
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
@@ -21,7 +21,6 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export

View File

@@ -57,7 +57,6 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Autopilots** — schedule recurring work for agents. Cron triggers, webhooks, or manual runs — each autopilot creates the issue and routes it to an agent automatically, so daily standups, weekly reports, and periodic audits run themselves.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
@@ -114,7 +113,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
### 2. Verify your runtime
@@ -124,7 +123,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task

View File

@@ -57,7 +57,6 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **自动化Autopilots** — 为 Agent 安排周期性工作。定时Cron、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
@@ -176,4 +175,4 @@ iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手
## 开源协议
[Modified Apache 2.0 (with commercial restrictions)](LICENSE)
[Apache 2.0](LICENSE)

View File

@@ -73,7 +73,7 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
@@ -135,262 +135,6 @@ multica daemon status
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
---
## Kubernetes Deployment (Alternative)
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the released OCI Helm chart at `oci://ghcr.io/multica-ai/charts/multica` or the source chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
The chart creates the following resources in the target namespace:
- `multica-postgres``pgvector/pgvector:pg17` backed by a 10Gi PVC
- `multica-backend` — Go API/WS server. Backed by a 5Gi `ReadWriteOnce` uploads PVC by default; set `backend.uploads.persistence.enabled=false` when you have configured S3 (`backend.config.s3Bucket`) and don't want the chart to declare the PVC at all.
- `multica-frontend` — Next.js standalone server
- Two `Ingress` resources: one for the web host, one for the backend host
- `multica-config` ConfigMap (rendered from `values.yaml`)
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
### Step 1 — Point hostnames at the cluster
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
```text
192.168.1.206 multica.dev.lan api.multica.dev.lan
```
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
### Step 2 — Create the namespace
```bash
kubectl create namespace multica
```
### Step 3 — Create the `multica-secrets` Secret
The chart references this Secret by name. Create it once with random values:
```bash
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
### Step 4 — Install the chart
```bash
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica
```
Released chart versions strip the leading `v` from the Git tag. For example, release tag `v0.3.5` publishes chart version `0.3.5`; the chart defaults the backend and frontend image tags to `v0.3.5`.
To override defaults, export the chart values, edit them, and pass them with `-f`:
```bash
helm show values oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> > my-values.yaml
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
When developing from a checkout, use the local chart path instead:
```bash
helm install multica deploy/helm/multica -n multica
```
Watch the pods come up:
```bash
kubectl -n multica get pods -w
```
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open http://multica.dev.lan in your browser.
### Step 5 — Log In
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
```bash
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
```bash
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
```
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml --set backend.config.appEnv=development
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
`ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml` (as `allowSignup`, `disableWorkspaceCreation`, and `googleClientId`). After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads all three from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 6 — Install CLI & Start Daemon
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
### Updating
To pull the latest images without changing the chart version when your values still use the mutable `latest` image tag:
```bash
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
```
To upgrade to a specific Multica release, upgrade to the matching chart version. The released chart defaults its app images to the matching Git tag:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
If you need to override the app images independently from the chart version, set the image tags in your values file:
```yaml
images:
backend:
tag: v0.2.4
frontend:
tag: v0.2.4
```
Then run the same upgrade command with `-f my-values.yaml`:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
To roll back if an upgrade goes sideways:
```bash
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
### Tearing down
```bash
# Remove the workloads but keep the PVCs and the Secret
helm -n multica uninstall multica
# Wipe everything, including PostgreSQL data and uploads
kubectl delete namespace multica
```
---
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
### Option A — External cron / systemd-timer (simplest)
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
### Option B — Swap Postgres for an image that ships `pg_cron`
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
### Option C — Backfill history first, then schedule
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
```bash
# Backfills task_usage_hourly from all historical task_usage rows and stamps
# the rollup watermark. Idempotent — safe to re-run.
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
## Stopping Services
If you installed via the install script:
@@ -431,8 +175,6 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
---
## Manual Docker Compose Setup

View File

@@ -44,10 +44,9 @@ Use this option when your deployment cannot reach the public internet or you alr
| `SMTP_PORT` | SMTP port | `25` |
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
| `SMTP_PASSWORD` | SMTP password | - |
| `SMTP_TLS` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces SMTPS on connect; port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS | `starttls` |
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is supported and auto-enables implicit TLS; set `SMTP_TLS=implicit` (aliases `smtps`, `ssl`) to force it on a non-standard port.
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
@@ -68,20 +67,8 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
| `DISABLE_WORKSPACE_CREATION` | Set to `true` to make `POST /api/workspaces` return 403 for every caller — users can only join workspaces they were invited to |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` and `DISABLE_WORKSPACE_CREATION` from `/api/config` at runtime, so no web rebuild is needed.
#### Locking down workspace creation
`ALLOW_SIGNUP=false` blocks new accounts from being created, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is:
1. Start the instance with `DISABLE_WORKSPACE_CREATION=false` (the default).
2. Sign in as the admin and create the shared workspace.
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to block new account creation.
4. Going forward, additional users join via invitation only — the "Create workspace" affordance is hidden in the UI and any direct API call returns 403.
> Note: setting `ALLOW_SIGNUP=false` blocks **all** new account creation, including users who already have a pending invitation. If you need invited users to be able to sign up but not create their own workspaces, keep `ALLOW_SIGNUP=true` (optionally combined with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`) and only flip `DISABLE_WORKSPACE_CREATION=true`.
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
@@ -179,111 +166,6 @@ The Docker Compose setup runs migrations automatically. If you need to run them
cd server && go run ./cmd/migrate up
```
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
Pick one of the supported paths:
### Option A — External cron / systemd-timer
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
Docker Compose:
```bash
# /etc/cron.d/multica-rollup
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Kubernetes (one-off `CronJob`):
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: multica-usage-rollup
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: psql
image: postgres:17-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- "SELECT rollup_task_usage_hourly();"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: multica-secrets
key: DATABASE_URL
```
### Option B — Postgres with `pg_cron`
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
Then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
### Option C — Backfill historical data first
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
| Flag | Description |
|---|---|
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
| `--dry-run` | Log slices that would be processed without writing anything. |
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:

View File

@@ -23,7 +23,7 @@ export default defineConfig({
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom", "@tanstack/react-query"],
dedupe: ["react", "react-dom"],
},
},
});

View File

@@ -45,21 +45,15 @@
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@tanstack/react-query": "catalog:",
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0",
"zustand": "catalog:"
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/eslint-config": "workspace:*",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@testing-library/jest-dom": "catalog:",
@@ -71,8 +65,9 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"globals": "^16.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:",
"vitest": "catalog:"

View File

@@ -15,7 +15,7 @@ import {
type StatsListener,
} from "fs";
import { join } from "path";
import { homedir, hostname } from "os";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
@@ -864,11 +864,6 @@ export function setupDaemonManager(
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
ipcMain.handle("daemon:get-status", () => fetchHealth());
// The host's OS name, available regardless of daemon state. The Runtimes
// page uses it as a fallback identity for "this machine" when no
// app-managed daemon is reporting a device name (e.g. the daemon runs
// out-of-band in WSL2). See desktop-runtimes-page.tsx.
ipcMain.handle("daemon:get-host-name", () => hostname());
ipcMain.handle(
"daemon:sync-token",
(_event, token: string, userId: string) => syncToken(token, userId),

View File

@@ -1,23 +1,16 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { setupLocalDirectory } from "./local-directory";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { installNavigationGestures } from "./navigation-gestures";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
createElectronReloadPrompt,
installRendererRecoveryHandlers,
type RendererRecoveryWindow,
} from "./renderer-recovery";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
@@ -229,6 +222,13 @@ function createWindow(): void {
log(level, `${message} (${sourceId}:${lineNumber})`);
});
// Fires when the renderer process dies for any reason (OOM, crash,
// killed). `details.reason` is the discriminator: "crashed", "oom",
// "killed", "abnormal-exit", "launch-failed", etc.
mainWindow.webContents.on("render-process-gone", (_event, details) => {
log("process-gone", JSON.stringify(details));
});
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
@@ -243,17 +243,15 @@ function createWindow(): void {
},
);
// Fires when the preload script throws before the renderer can boot.
// This is the one error class that NEVER reaches DevTools (preload
// runs before any window) — without this listener it's invisible.
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
});
}
installRendererRecoveryHandlers(mainWindow as unknown as RendererRecoveryWindow, {
isDev: is.dev,
showReloadPrompt: createElectronReloadPrompt((options) =>
dialog.showMessageBox(mainWindow!, options),
),
});
installContextMenu(mainWindow.webContents);
installNavigationGestures(mainWindow);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
@@ -460,7 +458,6 @@ if (!gotTheLock) {
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
setupLocalDirectory(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -1,93 +0,0 @@
import { ipcMain, dialog, BrowserWindow } from "electron";
import { access, stat } from "fs/promises";
import { constants as fsConstants } from "fs";
import { basename, isAbsolute } from "path";
export interface PickDirectoryResult {
ok: boolean;
path?: string;
basename?: string;
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
reason?: "cancelled" | "no_window" | "error";
error?: string;
}
export interface ValidateLocalDirectoryResult {
ok: boolean;
/** When ok=false, identifies which check failed so the renderer can render a
* specific message without parsing free-form text. */
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}
async function validateLocalDirectory(
path: string,
): Promise<ValidateLocalDirectoryResult> {
if (!path || !isAbsolute(path)) {
return { ok: false, reason: "not_absolute" };
}
try {
const st = await stat(path);
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { ok: false, reason: "not_found" };
return { ok: false, reason: "error", error: errorMessage(err) };
}
try {
await access(path, fsConstants.R_OK);
} catch {
return { ok: false, reason: "not_readable" };
}
try {
await access(path, fsConstants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function setupLocalDirectory(
windowGetter: () => BrowserWindow | null,
): void {
ipcMain.handle(
"local-directory:pick",
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
const win = windowGetter();
if (!win) return { ok: false, reason: "no_window" };
try {
const result = await dialog.showOpenDialog(win, {
// Multiple-selection is intentionally disabled — a project_resource
// points at a single directory, and the create flow expects one
// path per click. Multi-add would have to be a separate UX.
properties: ["openDirectory", "createDirectory"],
...(defaultPath ? { defaultPath } : {}),
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, reason: "cancelled" };
}
const picked = result.filePaths[0];
if (!picked) return { ok: false, reason: "cancelled" };
return { ok: true, path: picked, basename: basename(picked) };
} catch (err) {
return { ok: false, reason: "error", error: errorMessage(err) };
}
},
);
ipcMain.handle(
"local-directory:validate",
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
validateLocalDirectory(path),
);
}

View File

@@ -1,60 +0,0 @@
import type { BrowserWindow } from "electron";
import { describe, expect, it, vi } from "vitest";
import { NAVIGATION_GESTURE_CHANNEL } from "../shared/navigation-gestures";
import { installNavigationGestures } from "./navigation-gestures";
function makeWindow() {
let swipeHandler:
| ((event: unknown, direction: string) => void)
| undefined;
const win = {
on: vi.fn(
(event: string, handler: (event: unknown, direction: string) => void) => {
if (event === "swipe") swipeHandler = handler;
return win;
},
),
webContents: {
send: vi.fn(),
},
};
return {
win: win as unknown as BrowserWindow,
send: win.webContents.send,
emitSwipe: (direction: string) => swipeHandler?.({}, direction),
};
}
describe("installNavigationGestures", () => {
it("registers macOS swipe navigation", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("right");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "back");
emitSwipe("left");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "forward");
});
it("ignores non-horizontal swipe directions", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("up");
expect(send).not.toHaveBeenCalled();
});
it("does not register on non-mac platforms", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "linux");
emitSwipe("right");
expect(send).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +0,0 @@
import type { BrowserWindow } from "electron";
import {
NAVIGATION_GESTURE_CHANNEL,
navigationGestureFromSwipe,
} from "../shared/navigation-gestures";
export function installNavigationGestures(
win: BrowserWindow,
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== "darwin") return;
win.on("swipe", (_event, direction) => {
const gesture = navigationGestureFromSwipe(direction);
if (!gesture) return;
win.webContents.send(NAVIGATION_GESTURE_CHANNEL, gesture);
});
}

View File

@@ -1,112 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { installRendererRecoveryHandlers } from "./renderer-recovery";
type Handler = (...args: unknown[]) => void;
function makeWindow() {
const windowHandlers = new Map<string, Handler>();
const webContentsHandlers = new Map<string, Handler>();
const reload = vi.fn();
return {
window: {
on: vi.fn((event: string, handler: Handler) => windowHandlers.set(event, handler)),
isDestroyed: vi.fn(() => false),
webContents: {
on: vi.fn((event: string, handler: Handler) => webContentsHandlers.set(event, handler)),
reload,
},
},
windowHandlers,
webContentsHandlers,
reload,
};
}
describe("installRendererRecoveryHandlers", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
it("registers production reload prompts for renderer death and preload failure without auto reloading", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
expect(fixture.webContentsHandlers.has("render-process-gone")).toBe(true);
expect(fixture.webContentsHandlers.has("preload-error")).toBe(true);
expect(fixture.windowHandlers.has("unresponsive")).toBe(true);
expect(fixture.windowHandlers.has("responsive")).toBe(true);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
fixture.webContentsHandlers.get("preload-error")?.({}, "/preload.js", new Error("boom"));
expect(fixture.reload).not.toHaveBeenCalled();
await Promise.resolve();
expect(showReloadPrompt).toHaveBeenCalledTimes(2);
expect(fixture.reload).toHaveBeenCalledTimes(2);
});
it("does not prompt when the renderer exits cleanly", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
await Promise.resolve();
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("cancels an unresponsive prompt when the window becomes responsive again", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
fixture.windowHandlers.get("responsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("prompts for sustained unresponsive windows and only reloads after user confirmation", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps dev diagnostics non-prompting", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: true, showReloadPrompt, log: vi.fn() });
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
await Promise.resolve();
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
});

View File

@@ -1,135 +0,0 @@
export type RendererRecoveryWindow = {
isDestroyed: () => boolean;
on: (event: "unresponsive" | "responsive", handler: () => void) => unknown;
webContents: {
on: (event: string, handler: (...args: any[]) => void) => unknown;
reload: () => void;
};
};
type ReloadPromptPayload = {
kind: "render-process-gone" | "preload-error" | "unresponsive";
context: Record<string, unknown>;
};
type ReloadPromptResult = "reload" | "dismiss";
type RendererRecoveryOptions = {
isDev: boolean;
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
log?: (tag: string, ...args: unknown[]) => void;
unresponsivePromptDelayMs?: number;
};
export function installRendererRecoveryHandlers(
window: RendererRecoveryWindow,
{
isDev,
showReloadPrompt,
log = defaultDevLog,
unresponsivePromptDelayMs = 1500,
}: RendererRecoveryOptions,
) {
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
const maybePromptReload = (payload: ReloadPromptPayload) => {
if (isDev) return;
void showReloadPrompt(payload).then((result) => {
if (result === "reload" && !window.isDestroyed()) {
window.webContents.reload();
}
});
};
window.webContents.on("render-process-gone", (_event, details) => {
if (isDev) log("process-gone", JSON.stringify(details));
if (!isRecoverableRendererExit(details)) return;
maybePromptReload({ kind: "render-process-gone", context: { details } });
});
window.webContents.on("preload-error", (_event, preloadPath, error) => {
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
maybePromptReload({
kind: "preload-error",
context: { preloadPath, error: formatError(error) },
});
});
window.on("unresponsive", () => {
if (isDev || unresponsivePromptTimer) return;
unresponsivePromptTimer = setTimeout(() => {
unresponsivePromptTimer = null;
maybePromptReload({ kind: "unresponsive", context: {} });
}, unresponsivePromptDelayMs);
});
window.on("responsive", () => {
if (!unresponsivePromptTimer) return;
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
});
}
export function createElectronReloadPrompt(
showMessageBox: (options: {
type: "warning";
buttons: string[];
defaultId: number;
cancelId: number;
title: string;
message: string;
detail: string;
}) => Promise<{ response: number }>,
) {
return async (payload: ReloadPromptPayload): Promise<ReloadPromptResult> => {
const result = await showMessageBox({
type: "warning",
buttons: ["Reload", "Dismiss"],
defaultId: 0,
cancelId: 1,
title: "Multica needs to reload",
message: rendererRecoveryMessage(payload.kind),
detail: rendererRecoveryDetail(payload),
});
return result.response === 0 ? "reload" : "dismiss";
};
}
function isRecoverableRendererExit(details: unknown) {
if (!details || typeof details !== "object") return false;
const reason = (details as { reason?: unknown }).reason;
return (
reason === "crashed" ||
reason === "oom" ||
reason === "abnormal-exit" ||
reason === "launch-failed" ||
reason === "integrity-failure"
);
}
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
switch (kind) {
case "render-process-gone":
return "The desktop renderer process stopped responding or crashed.";
case "preload-error":
return "The desktop preload script failed before the app could start.";
case "unresponsive":
return "The desktop window is not responding.";
}
}
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
return [
"Reloading is the safest recovery path for this window.",
"",
`kind: ${payload.kind}`,
`context: ${JSON.stringify(payload.context)}`,
].join("\n");
}
function defaultDevLog(tag: string, ...args: unknown[]) {
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
}
function formatError(error: unknown) {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}

View File

@@ -1,6 +1,5 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -43,34 +42,6 @@ interface DesktopAPI {
issueKey: string;
}) => void,
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (
defaultPath?: string,
) => Promise<{
ok: boolean;
path?: string;
basename?: string;
reason?: "cancelled" | "no_window" | "error";
error?: string;
}>;
/** Validate that a path is an existing readable+writable directory.
* Mirrors the daemon's runtime check so the user sees errors before submit. */
validateLocalDirectory: (
path: string,
) => Promise<{
ok: boolean;
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}>;
}
interface DaemonStatus {
@@ -95,7 +66,6 @@ interface DaemonAPI {
stop: () => Promise<{ success: boolean; error?: string }>;
restart: () => Promise<{ success: boolean; error?: string }>;
getStatus: () => Promise<DaemonStatus>;
getHostName: () => Promise<string>;
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;

View File

@@ -1,11 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
type NavigationGesture,
} from "../shared/navigation-gestures";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -146,22 +141,6 @@ const desktopAPI = {
ipcRenderer.removeListener("inbox:open", handler);
};
},
/** Listen for native macOS back/forward swipe gestures. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
if (isNavigationGesture(gesture)) callback(gesture);
};
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),
/** Validate that a path is an existing readable+writable directory. */
validateLocalDirectory: (path: string) =>
ipcRenderer.invoke("local-directory:validate", path),
};
interface DaemonStatus {
@@ -185,8 +164,6 @@ const daemonAPI = {
ipcRenderer.invoke("daemon:restart"),
getStatus: (): Promise<DaemonStatus> =>
ipcRenderer.invoke("daemon:get-status"),
getHostName: (): Promise<string> =>
ipcRenderer.invoke("daemon:get-host-name"),
onStatusChange: (callback: (status: DaemonStatus) => void) => {
const handler = (_: unknown, status: DaemonStatus) => callback(status);
ipcRenderer.on("daemon:status", handler);

View File

@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale, type SupportedLocale } from "@multica/core/i18n";
import { pickLocale } from "@multica/core/i18n";
import { useAuthStore } from "@multica/core/auth";
import { useWelcomeStore } from "@multica/core/onboarding";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
@@ -21,18 +21,6 @@ import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { RESOURCES } from "@multica/views/locales";
// BCP-47 region tags for the <html lang> attribute, mirroring
// apps/web/app/layout.tsx HTML_LANG. index.html ships a static lang="en";
// we sync it to the resolved locale at boot so screen readers announce the
// right language AND the Japanese-scoped CJK font override in globals.css
// (`html[lang|="ja"]`) can take effect.
const HTML_LANG: Record<SupportedLocale, string> = {
en: "en",
"zh-Hans": "zh-CN",
ko: "ko-KR",
ja: "ja-JP",
};
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -191,7 +179,6 @@ function AppContent() {
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
// (synchronously after render, before paint) rather than the render
@@ -316,15 +303,6 @@ export default function App() {
[locale],
);
// Keep <html lang> in sync with the resolved locale (index.html hardcodes
// "en"). Drives the lang-scoped Japanese CJK font override and a11y.
// useLayoutEffect (not useEffect) so lang is committed before the first
// paint — otherwise Japanese users would see one frame of Kanji rendered
// with the Chinese-first fallback stack before the override kicks in.
useLayoutEffect(() => {
document.documentElement.lang = HTML_LANG[locale];
}, [locale]);
// React to OS-level language changes detected by main on focus regain.
// Only act when the user is following the system signal (no explicit
// Settings choice) — otherwise their preference wins. Cross-device sync

View File

@@ -1,54 +0,0 @@
import { useEffect, useState } from "react";
import { AgentsPage } from "@multica/views/agents";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `AgentsPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so the runtime
* machine filter can render the Local section the same way the Runtimes
* page does — without these props the page falls back to grouping
* every local-mode runtime under "Remote" with a generic title, which
* breaks the "drill from a machine into its agents" promise of the
* filter.
*
* Mirrors `DesktopRuntimesPage`: we cache the last seen daemon
* identity so the Local row doesn't get reclassified as Remote when
* the daemon is stopped (which would null out `status.daemonId`), and
* we fall back to the OS hostname so the section label stays useful
* even when the app doesn't manage the running daemon (WSL2 etc.).
*/
export function DesktopAgentsPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
const [hostName, setHostName] = useState<string | null>(null);
useEffect(() => {
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
window.daemonAPI.getHostName().then((name) => setHostName(name || null));
return window.daemonAPI.onStatusChange(apply);
}, []);
return (
<AgentsPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName ?? hostName}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row so the filter dropdown
// still has a Local option to pick in the empty window.
hasLocalMachine
/>
);
}

View File

@@ -34,7 +34,6 @@ function SidebarTopBar() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
@@ -43,7 +42,6 @@ function SidebarTopBar() {
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
@@ -56,20 +54,6 @@ function SidebarTopBar() {
);
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
useEffect(() => {
return window.desktopAPI.onNavigationGesture((gesture) => {
if (gesture === "back") {
goBack();
} else {
goForward();
}
});
}, [goBack, goForward]);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
@@ -148,7 +132,6 @@ function DesktopInboxBridge() {
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
useNativeNavigationGestures();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab

View File

@@ -28,11 +28,6 @@ export function DesktopRuntimesPage() {
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
// The host's OS hostname, independent of any daemon. Used as the last
// fallback for the local machine name so consolidation still works when
// the app doesn't manage the running daemon (e.g. it lives in WSL2) and
// thus never reports a device name.
const [hostName, setHostName] = useState<string | null>(null);
useEffect(() => {
const apply = (s: DaemonStatus) => {
@@ -45,7 +40,6 @@ export function DesktopRuntimesPage() {
}
};
window.daemonAPI.getStatus().then(apply);
window.daemonAPI.getHostName().then((name) => setHostName(name || null));
return window.daemonAPI.onStatusChange(apply);
}, []);
@@ -57,7 +51,7 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName ?? hostName}
localMachineName={status.deviceName ?? lastIdentity.deviceName}
localMachineActions={<DaemonRuntimeActions />}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared

View File

@@ -1,98 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
const openModal = vi.fn();
const reloadActiveTab = vi.fn();
const closeActiveTab = vi.fn();
vi.mock("@multica/core/modals", () => ({
useModalStore: {
getState: () => ({ open: openModal }),
},
}));
vi.mock("@/stores/tab-store", () => ({
useTabStore: {
getState: () => ({ reloadActiveTab, closeActiveTab }),
},
}));
import { DesktopRouteErrorPage, formatRouteErrorReport } from "./route-error-page";
function Boom(): null {
throw new Error("route render exploded");
return null;
}
describe("DesktopRouteErrorPage", () => {
beforeEach(() => {
openModal.mockReset();
reloadActiveTab.mockReset();
closeActiveTab.mockReset();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("brands React Router route errors and offers tab reload", async () => {
const router = createMemoryRouter(
[{ path: "/", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Something went wrong in this tab",
);
fireEvent.click(screen.getByRole("button", { name: /reload tab/i }));
expect(reloadActiveTab).toHaveBeenCalledTimes(1);
});
it("offers Close tab as the always-safe escape from a crashing route", async () => {
const router = createMemoryRouter(
[{ path: "/acme/issues/1", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/acme/issues/1"] },
);
render(<RouterProvider router={router} />);
fireEvent.click(await screen.findByRole("button", { name: /close tab/i }));
expect(closeActiveTab).toHaveBeenCalledTimes(1);
});
it("opens the existing feedback modal with a structured markdown report only after click", async () => {
const router = createMemoryRouter(
[{ path: "/acme/issues", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/acme/issues"] },
);
render(<RouterProvider router={router} />);
expect(openModal).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: /report error/i }));
expect(openModal).toHaveBeenCalledWith(
"feedback",
expect.objectContaining({
initialMessage: expect.stringContaining("kind: desktop_route_error"),
}),
);
});
it("documents the structured kind/context follow-up debt in the report template", () => {
const report = formatRouteErrorReport({
error: new Error("bad route"),
url: "app://desktop/acme/issues",
appInfo: { version: "1.2.3", os: "macos" },
trigger: "route-errorElement",
});
expect(report).toContain("kind: desktop_route_error");
expect(report).toContain("trigger: route-errorElement");
expect(report).toContain("TODO: promote kind/context to structured feedback fields");
});
});

View File

@@ -1,140 +0,0 @@
import { useMemo } from "react";
import { useLocation, useNavigate, useRouteError } from "react-router-dom";
import { AlertTriangle, RotateCw, Send, X } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useModalStore } from "@multica/core/modals";
import { useTabStore } from "@/stores/tab-store";
type DesktopAppInfo = {
version?: string;
os?: string;
};
export function formatRouteErrorReport({
error,
url,
appInfo,
trigger,
}: {
error: unknown;
url: string;
appInfo?: DesktopAppInfo;
trigger: string;
}) {
const normalized = normalizeError(error);
return [
"kind: desktop_route_error",
`trigger: ${trigger}`,
`url: ${url}`,
`app_version: ${appInfo?.version ?? "unknown"}`,
`runtime_os: ${appInfo?.os ?? "unknown"}`,
"",
"context:",
`- name: ${normalized.name}`,
`- message: ${normalized.message}`,
"",
"stack:",
"```",
normalized.stack ?? "<no stack>",
"```",
"",
"TODO: promote kind/context to structured feedback fields once the feedback API supports them.",
].join("\n");
}
export function DesktopRouteErrorPage() {
const error = useRouteError();
const location = useLocation();
const navigate = useNavigate();
const workspaceSlug = location.pathname.split("/").filter(Boolean)[0];
const safeRoute = workspaceSlug ? `/${workspaceSlug}/issues` : null;
const report = useMemo(
() =>
formatRouteErrorReport({
error,
url:
typeof window !== "undefined"
? `${window.location.origin}${location.pathname}${location.search}${location.hash}`
: location.pathname,
appInfo: typeof window !== "undefined" ? window.desktopAPI?.appInfo : undefined,
trigger: "route-errorElement",
}),
[error, location.hash, location.pathname, location.search],
);
const message = normalizeError(error).message;
return (
<div
role="alert"
className="flex h-full min-h-[20rem] flex-col items-center justify-center gap-4 p-8 text-center"
>
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="h-6 w-6" aria-hidden="true" />
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold">Something went wrong in this tab</h2>
<p className="max-w-lg text-sm text-muted-foreground">
A route-level renderer error was contained before it could take down the
desktop shell. Reload this tab, or send the report if it keeps happening.
</p>
<p className="max-w-lg truncate text-xs text-muted-foreground">{message}</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => useTabStore.getState().reloadActiveTab()}
>
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
Reload tab
</Button>
{safeRoute ? (
<Button type="button" variant="outline" onClick={() => navigate(safeRoute, { replace: true })}>
Go to issues
</Button>
) : null}
<Button
type="button"
variant="outline"
onClick={() => useTabStore.getState().closeActiveTab()}
>
<X className="mr-2 h-4 w-4" aria-hidden="true" />
Close tab
</Button>
<Button
type="button"
onClick={() =>
useModalStore.getState().open("feedback", {
initialMessage: report,
})
}
>
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
Report error
</Button>
</div>
</div>
);
}
function normalizeError(error: unknown): { name: string; message: string; stack?: string } {
if (error instanceof Error) {
return {
name: error.name || "Error",
message: error.message || "Unknown route error",
stack: error.stack,
};
}
if (typeof error === "string") {
return { name: "Error", message: error };
}
return { name: "Error", message: "Unknown route error", stack: safeJson(error) };
}
function safeJson(value: unknown) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -124,7 +124,6 @@ function SortableTabItem({
const tabButton = (
<button
type="button"
ref={setNodeRef}
style={style}
{...attributes}
@@ -222,7 +221,6 @@ function NewTabButton() {
return (
<button
type="button"
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"

View File

@@ -3,7 +3,6 @@ import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
import type { Tab } from "@/stores/tab-store";
/**
@@ -16,28 +15,6 @@ function TabRouterInner({ tab }: { tab: Tab }) {
return null;
}
/**
* Wraps a tab's subtree so its scroll position survives the round trip
* through `<Activity mode="hidden">`. Lives inside Activity so the hook's
* effects cycle with the tab's visibility — see `useTabScrollRestore` for
* the mechanism. `display: contents` keeps the wrapper transparent to
* the surrounding flex layout.
*/
function TabScrollRestoreWrapper({
tabPath,
children,
}: {
tabPath: string;
children: React.ReactNode;
}) {
const ref = useTabScrollRestore(tabPath);
return (
<div ref={ref} style={{ display: "contents" }}>
{children}
</div>
);
}
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
@@ -67,12 +44,10 @@ export function TabContent() {
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
>
<TabScrollRestoreWrapper tabPath={tab.path}>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</TabScrollRestoreWrapper>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</Activity>
))}
</>

View File

@@ -26,7 +26,6 @@ export function UpdateNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
@@ -44,14 +43,12 @@ export function UpdateNotification() {
</p>
<div className="mt-2 flex items-center gap-1.5">
<button
type="button"
onClick={() => setDismissed(true)}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Later
</button>
<button
type="button"
onClick={() => window.updater.installUpdate()}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// vi.hoisted shared state for all the stores / hooks the layout consumes.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
isAuthLoading: false,
overlay: null as { type: string } | null,
workspace: null as { id: string; slug: string } | null,
listFetched: true,
wsList: [] as { id: string; slug: string }[],
workspaceSeen: true,
modalRenders: 0,
modalAriaLabel: "source-backfill-modal-marker",
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) => {
if (selector.toString().includes("isLoading"))
return state.isAuthLoading;
return state.user;
};
return { useAuthStore };
});
vi.mock("@multica/core/platform", () => ({
setCurrentWorkspace: vi.fn(),
}));
vi.mock("@multica/core/workspace", async () => {
const actual = await vi.importActual<typeof import("@multica/core/workspace")>(
"@multica/core/workspace",
);
return {
...actual,
workspaceBySlugOptions: () => ({
queryKey: ["workspace-by-slug"],
queryFn: async () => state.workspace,
}),
workspaceListOptions: () => ({
queryKey: ["workspace-list"],
queryFn: async () => state.wsList,
}),
};
});
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
WorkspaceSlugProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
paths: {
...actual.paths,
login: () => "/login",
},
};
});
vi.mock("@multica/views/workspace/use-workspace-seen", () => ({
useWorkspaceSeen: () => state.workspaceSeen,
}));
vi.mock("@multica/views/workspace/welcome-after-onboarding", () => ({
WelcomeAfterOnboarding: () => null,
}));
vi.mock("@multica/views/layout", () => ({
WorkspacePresencePrefetch: () => null,
}));
// The point of this whole test: assert the desktop layout mounts the
// SourceBackfillModal. We stub the real component with a marker that
// renders only when the layout actually rendered it (and not e.g.
// suppressed by overlayActive).
vi.mock("@multica/views/onboarding", () => ({
SourceBackfillModal: () => {
state.modalRenders += 1;
return <div data-testid={state.modalAriaLabel} />;
},
}));
vi.mock("@/stores/tab-store", () => ({
useTabStore: Object.assign(() => null, {
getState: () => ({ validateWorkspaceSlugs: vi.fn() }),
}),
}));
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WorkspaceRouteLayout } from "./workspace-route-layout";
function renderLayout() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
// Seed the workspace queries so the gate inside the layout passes
// synchronously — the real hook reads from cache.
qc.setQueryData(["workspace-by-slug"], state.workspace);
qc.setQueryData(["workspace-list"], state.wsList);
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/acme/issues"]}>
<Routes>
<Route path=":workspaceSlug/*" element={<WorkspaceRouteLayout />}>
<Route path="*" element={<div data-testid="outlet" />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
state.user = { id: "u1" };
state.isAuthLoading = false;
state.overlay = null;
state.workspace = { id: "ws-1", slug: "acme" };
state.listFetched = true;
state.wsList = [{ id: "ws-1", slug: "acme" }];
state.workspaceSeen = true;
state.modalRenders = 0;
});
describe("WorkspaceRouteLayout", () => {
it("mounts SourceBackfillModal when no WindowOverlay is active", () => {
const { queryByTestId } = renderLayout();
expect(queryByTestId(state.modalAriaLabel)).not.toBeNull();
expect(state.modalRenders).toBeGreaterThan(0);
});
it("suppresses SourceBackfillModal while a WindowOverlay is active", () => {
state.overlay = { type: "new-workspace" };
const { queryByTestId } = renderLayout();
expect(queryByTestId(state.modalAriaLabel)).toBeNull();
expect(state.modalRenders).toBe(0);
});
});

View File

@@ -11,7 +11,6 @@ import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
import { WorkspacePresencePrefetch } from "@multica/views/layout";
import { SourceBackfillModal } from "@multica/views/onboarding";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
@@ -105,13 +104,6 @@ export function WorkspaceRouteLayout() {
* Modal — unless the store signal has already been consumed, in
* which case the hook renders null. */}
{!overlayActive && <WelcomeAfterOnboarding />}
{/* Source-attribution backfill: same Dialog the web shell mounts
* inside DashboardLayout. Desktop's WorkspaceRouteLayout doesn't
* wrap DashboardLayout, so the modal has to be wired in directly
* here. Same overlay-suppression rule as WelcomeAfterOnboarding —
* a portal-rendered Dialog at z-50 would otherwise sit above an
* active pre-workspace overlay. */}
{!overlayActive && <SourceBackfillModal />}
</WorkspaceSlugProvider>
);
}

View File

@@ -1,58 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
function expectChineseFontsBeforeKoreanFonts(source: string) {
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
const koreanIndexes = koreanFonts.map((font) => source.indexOf(font));
expect(chineseIndexes).not.toContain(-1);
expect(koreanIndexes).not.toContain(-1);
for (const chineseIndex of chineseIndexes) {
for (const koreanIndex of koreanIndexes) {
expect(chineseIndex).toBeLessThan(koreanIndex);
}
}
}
// Japanese Kanji share the Han Unicode block with Chinese, so the global
// Chinese-first stack must stay Chinese-first (no zh regression) while a
// Japanese-first CJK stack is scoped to html[lang|="ja"]. App.tsx syncs
// document.documentElement.lang so the selector matches at runtime.
function expectJapaneseScopedOverride(source: string) {
expect(source).toContain('html[lang|="ja"]');
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
expect(japaneseIndexes).not.toContain(-1);
const firstJapanese = Math.min(...japaneseIndexes);
const lastChinese = Math.max(
...chineseFonts.map((font) => source.lastIndexOf(font)),
);
expect(firstJapanese).toBeLessThan(lastChinese);
}
describe("CJK font fallback order", () => {
it("keeps desktop Chinese font fallbacks before Korean font fallbacks", () => {
const desktopCss = readFileSync(
resolve(process.cwd(), "src/renderer/src/globals.css"),
"utf8",
);
expectChineseFontsBeforeKoreanFonts(desktopCss);
});
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
const desktopCss = readFileSync(
resolve(process.cwd(), "src/renderer/src/globals.css"),
"utf8",
);
expectJapaneseScopedOverride(desktopCss);
});
});

View File

@@ -6,15 +6,16 @@
@custom-variant dark (&:is(.dark *));
/* Font stack: Inter for Latin UI text + system CJK fonts for localized content.
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
keep the CJK fallback tail in sync across both files. The Inter primary family
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
Both resolve to Inter glyphs, so rendering is identical in practice.
Per-character fallback: Latin chars render with Inter, CJK chars render with
the platform-native Chinese/Korean fallback when needed. Chinese fonts must stay
before Korean fonts so zh users do not receive Korean Hanja glyph shapes.
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
Per-character fallback: Latin chars render with Inter, Chinese chars with
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
@@ -22,35 +23,14 @@
the rare mixed case correctly. */
:root {
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
"Apple Garamond", Baskerville, "Times New Roman", serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}
/* Japanese-scoped CJK override. Japanese Kanji share the Han Unicode block
with Chinese, and CSS font-fallback order is not changed by <html lang> —
so the global Chinese-first stack above would give Japanese users Chinese
glyph shapes for shared ideographs. We keep the global stack Chinese-first
(no regression for zh users) and promote Japanese fonts ahead of the
Chinese/Korean families only when the locale is Japanese. App.tsx syncs
document.documentElement.lang to the active locale so this selector
matches. Mirrors the lang-scoped override in apps/web/app/layout.tsx.
`[lang|="ja"]` is the BCP-47 language-range selector: it matches exactly
`ja` or `ja-<region>` (App.tsx sets `ja-JP`), never unrelated subtags
such as `jam`. */
html[lang|="ja"] {
--font-sans: "Inter Variable", "Inter", "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Yu Gothic", "YuGothic", "Meiryo",
"Noto Sans CJK JP", "Noto Sans JP", -apple-system,
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
}
@source "../../../../../packages/ui/**/*.tsx";
@source "../../../../../packages/core/**/*.{ts,tsx}";
@source "../../../../../packages/views/**/*.{ts,tsx}";

View File

@@ -1,116 +0,0 @@
import { Activity } from "react";
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { useTabScrollRestore } from "./use-tab-scroll-restore";
function Harness({ path }: { path: string }) {
const ref = useTabScrollRestore(path);
return (
<div ref={ref} style={{ display: "contents" }}>
<div
data-tab-scroll-root
data-testid="scroller"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-tab-scroll-root="aside"
data-testid="aside"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-testid="unmarked"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
</div>
);
}
function App({ visible, path }: { visible: boolean; path: string }) {
return (
<Activity mode={visible ? "visible" : "hidden"}>
<Harness path={path} />
</Activity>
);
}
function setScroll(el: HTMLElement, top: number) {
el.scrollTop = top;
fireEvent.scroll(el);
}
describe("useTabScrollRestore", () => {
it("restores scroll position when a tab cycles through hidden -> visible", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
expect(scroller.scrollTop).toBe(500);
// Simulate Activity hiding the subtree: layout would drop the offset.
rerender(<App visible={false} path="/acme/issues/1" />);
scroller.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(scroller.scrollTop).toBe(500);
});
it("restores multiple named scroll roots independently", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const main = getByTestId("scroller") as HTMLElement;
const aside = getByTestId("aside") as HTMLElement;
setScroll(main, 300);
setScroll(aside, 150);
rerender(<App visible={false} path="/acme/issues/1" />);
main.scrollTop = 0;
aside.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(main.scrollTop).toBe(300);
expect(aside.scrollTop).toBe(150);
});
it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const unmarked = getByTestId("unmarked") as HTMLElement;
setScroll(unmarked, 250);
rerender(<App visible={false} path="/acme/issues/1" />);
unmarked.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(unmarked.scrollTop).toBe(0);
});
it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
// Navigating within the tab swaps the active route — same marker key,
// different page. We should NOT restore the prior page's offset.
rerender(<App visible={true} path="/acme/issues/2" />);
scroller.scrollTop = 0;
rerender(<App visible={false} path="/acme/issues/2" />);
rerender(<App visible={true} path="/acme/issues/2" />);
expect(scroller.scrollTop).toBe(0);
});
});

View File

@@ -1,106 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Persist a tab's scroll positions across <Activity> visibility transitions.
*
* Tabs render under `<Activity mode="visible|hidden">`, which keeps React
* state but loses DOM scrollTop — the subtree is taken out of layout while
* hidden and rejoins with scrollTop=0. This hook records every marked
* container's `scrollTop` while the tab is visible (continuously, via a
* capture-phase scroll listener) and restores them in a `useLayoutEffect`
* the next time the tab becomes visible, before the browser paints.
*
* Mark scroll containers in views with `data-tab-scroll-root`. The
* attribute value is the cache key — defaults to `"main"` for unnamed
* roots. Most pages have a single scroll container, so a bare attribute
* is enough; named keys are only needed when a page has multiple
* independently scrollable regions whose positions must all be restored.
*
* When the tab's path changes (intra-tab navigation), the saved offsets
* are dropped — the new route's container shares the same marker key but
* is a different page, and restoring the old offset would land the user
* somewhere arbitrary on the new page.
*
* For virtualized children (Virtuoso, react-virtual, etc.) the single
* synchronous `scrollTop = saved` inside useLayoutEffect isn't enough:
* the child registers its observers in a passive useEffect that fires
* later, so at restore time the container's scrollHeight has collapsed
* to clientHeight and the browser clamps our assignment to 0. The
* restore loops across rAF frames until the assignment sticks, which
* lets virtualization rehydrate before we give up.
*/
export function useTabScrollRestore(tabPath: string) {
const containerRef = useRef<HTMLDivElement>(null);
const savedRef = useRef<Map<string, number>>(new Map());
const prevPathRef = useRef(tabPath);
if (prevPathRef.current !== tabPath) {
savedRef.current.clear();
prevPathRef.current = tabPath;
}
// <Activity> cleans up effects on hidden and re-mounts them on visible,
// so an empty-deps useLayoutEffect runs exactly on every hidden → visible
// transition. Restoring here (before the browser paints) handles the
// common case without a flash.
//
// The synchronous set isn't enough for virtualized lists though
// (issue-detail uses Virtuoso with customScrollParent). Virtuoso wires
// its scroll/resize observers in a passive useEffect, which fires AFTER
// useLayoutEffect — so at the moment we try to restore, the spacer that
// gives the container its tall scrollHeight hasn't been re-established
// yet. The browser silently clamps `scrollTop = saved` down to 0 because
// `scrollHeight === clientHeight` in that window. Retry across rAF
// frames until the set sticks (or we time out around the time any sane
// child should have laid out, ~500ms).
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
const cancellers: Array<() => void> = [];
els.forEach((el) => {
const key = scrollKey(el);
const saved = savedRef.current.get(key);
if (saved === undefined) return;
el.scrollTop = saved;
if (el.scrollTop === saved) return;
let cancelled = false;
let attempts = 0;
const maxAttempts = 30; // ~500ms at 60fps
const tick = () => {
if (cancelled) return;
el.scrollTop = saved;
attempts++;
if (el.scrollTop === saved) return;
if (attempts >= maxAttempts) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
cancellers.push(() => {
cancelled = true;
});
});
return () => cancellers.forEach((c) => c());
}, []);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const onScroll = (e: Event) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (!target.hasAttribute("data-tab-scroll-root")) return;
savedRef.current.set(scrollKey(target), target.scrollTop);
};
// Scroll events don't bubble, but capture catches them anyway.
root.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => root.removeEventListener("scroll", onScroll, true);
}, []);
return containerRef;
}
function scrollKey(el: HTMLElement): string {
return el.getAttribute("data-tab-scroll-root") || "main";
}

View File

@@ -1,6 +1,7 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetail } from "@multica/views/issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
@@ -13,8 +14,9 @@ export function IssueDetailPage() {
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
if (!id) return null;
// Render errors bubble to the root route errorElement (DesktopRouteErrorPage),
// which contains the crash inside the tab content pane. No page-level boundary
// here — a whole-page wrapper duplicates the route-level error UI.
return <IssueDetail issueId={id} />;
return (
<ErrorBoundary resetKeys={[id]}>
<IssueDetail issueId={id} />
</ErrorBoundary>
);
}

View File

@@ -6,7 +6,7 @@ import { useEffect } from "react";
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
type MockRouter = {
state: { location: { pathname: string; search: string; hash: string } };
state: { location: { pathname: string } };
navigate: ReturnType<typeof vi.fn>;
};
@@ -17,9 +17,9 @@ type MockTab = {
router: MockRouter;
};
function makeMockRouter(pathname: string, search = "", hash = ""): MockRouter {
function makeMockRouter(pathname: string): MockRouter {
return {
state: { location: { pathname, search, hash } },
state: { location: { pathname } },
navigate: vi.fn(),
};
}
@@ -263,32 +263,6 @@ describe("DesktopNavigationProvider.push with pinned active tab", () => {
});
});
describe("DesktopNavigationProvider.push duplicate path guard", () => {
it("does not navigate when the target exactly matches the active tab location", () => {
const activeRouter = makeMockRouter("/acme/issues/child");
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: "/acme/issues/child",
pinned: false,
router: activeRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues/child");
expect(activeRouter.navigate).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
@@ -325,46 +299,6 @@ describe("TabNavigationProvider.openInNewTab", () => {
});
});
describe("TabNavigationProvider.push duplicate path guard", () => {
function renderTabProviderAt(
pathname: string,
search = "",
hash = "",
) {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname, search, hash } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("does not navigate when the target exactly matches the current full location", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues/child");
getAdapter().push("/acme/issues/child");
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("still navigates when only search or hash differs", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues");
getAdapter().push("/acme/issues?filter=open#top");
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open#top");
});
});
describe("TabNavigationProvider.push with pinned active tab", () => {
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
@@ -376,7 +310,7 @@ describe("TabNavigationProvider.push with pinned active tab", () => {
// router.navigate when no interception fires. In real desktop usage they
// are the same router instance; this helper mirrors that invariant.
const fakeRouter = {
state: { location: { pathname, search: "", hash: "" } },
state: { location: { pathname, search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as ProviderRouter;

View File

@@ -89,11 +89,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
return false;
}
function routerLocationPath(router: DataRouter): string {
const { pathname, search, hash } = router.state.location;
return `${pathname}${search ?? ""}${hash ?? ""}`;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
@@ -200,7 +195,6 @@ export function DesktopNavigationProvider({
}
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (active && routerLocationPath(active.router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
@@ -277,7 +271,6 @@ export function TabNavigationProvider({
() => ({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (routerLocationPath(router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
router.navigate(path);

View File

@@ -21,16 +21,16 @@ import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { DesktopAgentsPage } from "./components/desktop-agents-page";
import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { useT } from "@multica/views/i18n";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
import { DesktopRouteErrorPage } from "./components/route-error-page";
/**
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
@@ -109,7 +109,6 @@ function PageShell() {
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
errorElement: <DesktopRouteErrorPage />,
children: [
{ index: true, element: null },
{
@@ -119,7 +118,11 @@ export const appRoutes: RouteObject[] = [
{ index: true, element: <Navigate to="issues" replace /> },
{
path: "issues",
element: <IssuesPage />,
element: (
<ErrorBoundary>
<IssuesPage />
</ErrorBoundary>
),
handle: { title: "Issues" },
},
{
@@ -168,7 +171,7 @@ export const appRoutes: RouteObject[] = [
element: <SkillDetailPage />,
handle: { title: "Skill" },
},
{ path: "agents", element: <DesktopAgentsPage />, handle: { title: "Agents" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{
path: "agents/:id",
element: <AgentDetailPage />,

View File

@@ -259,47 +259,6 @@ describe("useTabStore actions", () => {
expect(s.activeWorkspaceSlug).toBeNull();
});
it("validateWorkspaceSlugs seeds the first valid workspace when no group exists", () => {
const store = useTabStore.getState();
store.validateWorkspaceSlugs(new Set(["acme", "butter"]));
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("validateWorkspaceSlugs reactivates an existing valid group before seeding", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const existingTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
useTabStore.setState({ activeWorkspaceSlug: null });
store.validateWorkspaceSlugs(new Set(["acme"]));
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].id).toBe(existingTabId);
});
it("validateWorkspaceSlugs seeds a fresh tab for a valid slug after dropping all stale groups", () => {
const store = useTabStore.getState();
// The only persisted group points at a workspace the user has lost access
// to — the stale-tab heal path WorkspaceRouteLayout drives.
store.switchWorkspace("stale");
const staleRouter = useTabStore.getState().byWorkspace.stale.tabs[0].router;
store.validateWorkspaceSlugs(new Set(["acme"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["acme"]);
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
// The dropped stale group's router must be disposed, not leaked.
expect(staleRouter.dispose).toHaveBeenCalled();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");

View File

@@ -86,16 +86,6 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Recreate the active tab's router at the same path after a route-level crash. */
reloadActiveTab: () => void;
/**
* Close the active tab. The always-safe escape from a route-level crash:
* unlike reloadActiveTab (recreates the same crashing path) or navigating
* to a "safe" route (which may itself be the route that crashed), closing
* destroys the crashing router entirely and falls back to a sibling tab
* (or a reseeded default if it was the last tab).
*/
closeActiveTab: () => void;
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
@@ -485,38 +475,6 @@ export const useTabStore = create<TabStore>()(
});
},
reloadActiveTab() {
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
if (index < 0) return;
const current = group.tabs[index];
const nextTabs = [...group.tabs];
nextTabs[index] = {
...current,
router: createTabRouter(current.path),
historyIndex: 0,
historyLength: 1,
};
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
},
});
window.setTimeout(() => current.router.dispose(), 0);
},
closeActiveTab() {
const { activeWorkspaceSlug, byWorkspace, closeTab } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
closeTab(group.activeTabId);
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();
@@ -599,24 +557,6 @@ export const useTabStore = create<TabStore>()(
changed = true;
}
if (!nextActive) {
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
if (nextActive) changed = true;
}
if (!nextActive) {
const fallbackSlug = validSlugs.values().next().value;
if (fallbackSlug) {
const fresh = defaultTabFor(fallbackSlug);
nextByWorkspace[fallbackSlug] = {
tabs: [fresh],
activeTabId: fresh.id,
};
nextActive = fallbackSlug;
changed = true;
}
}
if (!changed) return;
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
},

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isNavigationGesture,
navigationGestureFromSwipe,
} from "./navigation-gestures";
describe("navigationGestureFromSwipe", () => {
it("maps horizontal macOS swipe directions to browser-style history", () => {
expect(navigationGestureFromSwipe("right")).toBe("back");
expect(navigationGestureFromSwipe("left")).toBe("forward");
});
it("ignores vertical and unknown directions", () => {
expect(navigationGestureFromSwipe("up")).toBeNull();
expect(navigationGestureFromSwipe("down")).toBeNull();
expect(navigationGestureFromSwipe("sideways")).toBeNull();
});
});
describe("isNavigationGesture", () => {
it("accepts only the renderer navigation gestures", () => {
expect(isNavigationGesture("back")).toBe(true);
expect(isNavigationGesture("forward")).toBe(true);
expect(isNavigationGesture("right")).toBe(false);
expect(isNavigationGesture(null)).toBe(false);
});
});

View File

@@ -1,15 +0,0 @@
export const NAVIGATION_GESTURE_CHANNEL = "navigation:gesture";
export type NavigationGesture = "back" | "forward";
export function isNavigationGesture(value: unknown): value is NavigationGesture {
return value === "back" || value === "forward";
}
export function navigationGestureFromSwipe(
direction: string,
): NavigationGesture | null {
if (direction === "right") return "back";
if (direction === "left") return "forward";
return null;
}

View File

@@ -11,7 +11,6 @@ import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
import { docsSlugStaticParams } from "@/lib/static-params";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
@@ -23,11 +22,11 @@ export default async function Page(props: {
params: Promise<{ lang: string; slug: string[] }>;
}) {
const params = await props.params;
const lang = asLang(params.lang);
const page = source.getPage(params.slug, lang);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
const MDX = page.data.body;
const lang = asLang(params.lang);
return (
<DocsPage toc={page.data.toc}>
@@ -43,15 +42,14 @@ export default async function Page(props: {
}
export function generateStaticParams() {
return docsSlugStaticParams(source.generateParams());
return source.generateParams().filter((p) => p.slug.length > 0);
}
export async function generateMetadata(props: {
params: Promise<{ lang: string; slug: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const lang = asLang(params.lang);
const page = source.getPage(params.slug, lang);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {

View File

@@ -11,13 +11,18 @@ import { i18n, type Lang } from "@/lib/i18n";
import { uiTranslations, localeLabels } from "@/lib/translations";
import { DocsSettings } from "@/components/docs-settings";
// Inter (Latin UI face) is exposed under `--font-inter`. The full `--font-sans`
// stack — Inter + the per-locale CJK fallback chain, including the Japanese-first
// override scoped to `<html lang="ja">` — is composed in static CSS in
// ./global.css (CSP-safe, no inline <style>). Mirrors apps/web/app/layout.tsx.
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
variable: "--font-sans",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"sans-serif",
],
});
const geistMono = Geist_Mono({

View File

@@ -17,42 +17,8 @@ function tokenizeCJK(raw: string): string[] {
return tokens;
}
// Japanese mixes Hiragana, Katakana and Kanji; the English regex strips them
// all, and the zh tokenizer only keeps Han (Kanji), dropping kana entirely.
// Tokenize each kana/Kanji codepoint on its own and keep Latin/digit runs
// whole — same character-level recall strategy as tokenizeCJK, extended to
// the Hiragana (\u3040-\u309f) and Katakana (\u30a0-\u30ff) blocks, plus the
// ideographic iteration mark \u3005 which sits just below the kana blocks and
// recurs in common words (e.g. the JP for "various", "daily", "individual").
function tokenizeJapanese(raw: string): string[] {
const tokens: string[] = [];
const regex = /[\u3005\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]|[A-Za-z0-9]+/g;
const lower = raw.toLowerCase();
let match: RegExpExecArray | null;
while ((match = regex.exec(lower)) !== null) {
tokens.push(match[0]);
}
return tokens;
}
export const { GET } = createFromSource(source, {
localeMap: {
ko: {
components: {
tokenizer: {
language: "english",
},
},
},
ja: {
components: {
tokenizer: {
language: "english",
normalizationCache: new Map(),
tokenize: tokenizeJapanese,
},
},
},
zh: {
components: {
tokenizer: {

View File

@@ -1,57 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
function expectChineseFontsBeforeKoreanFonts(source: string) {
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
const koreanIndexes = koreanFonts.map((font) => source.indexOf(font));
expect(chineseIndexes).not.toContain(-1);
expect(koreanIndexes).not.toContain(-1);
for (const chineseIndex of chineseIndexes) {
for (const koreanIndex of koreanIndexes) {
expect(chineseIndex).toBeLessThan(koreanIndex);
}
}
}
// Japanese Kanji share the Han Unicode block with Chinese, so the docs
// Japanese-first CJK stack must be scoped to html[lang|="ja"] (zh/en keep
// Chinese-first) and order Japanese fonts before the Chinese families.
function expectJapaneseScopedOverride(source: string) {
expect(source).toContain('html[lang|="ja"]');
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
expect(japaneseIndexes).not.toContain(-1);
const firstJapanese = Math.min(...japaneseIndexes);
const lastChinese = Math.max(
...chineseFonts.map((font) => source.lastIndexOf(font)),
);
expect(firstJapanese).toBeLessThan(lastChinese);
}
describe("CJK font fallback order", () => {
it("keeps docs Chinese font fallbacks before Korean font fallbacks", () => {
const cssSource = readFileSync(
resolve(process.cwd(), "app/global.css"),
"utf8",
);
expectChineseFontsBeforeKoreanFonts(cssSource);
});
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
const cssSource = readFileSync(
resolve(process.cwd(), "app/global.css"),
"utf8",
);
expectJapaneseScopedOverride(cssSource);
});
});

View File

@@ -6,36 +6,6 @@
@source "../../../packages/ui/**/*.{ts,tsx}";
/* ---------------------------------------------------------------------------
* Font stack. `--font-inter` is the next/font Inter family (+ synthetic
* size-adjusted fallback), set on <html> by inter.variable in app/[lang]/layout.tsx.
* `--font-sans` is composed here in static CSS so it can be overridden per
* `<html lang>` and stays CSP-safe (no inline <style>). Tailwind's `font-sans`
* utility resolves `var(--font-sans)`. Mirrors apps/web/app/globals.css.
*
* Default (en / zh / ko): Latin → Inter, CJK → Chinese then Korean. Chinese MUST
* stay before Korean so zh users don't get Korean Hanja glyph shapes.
* ------------------------------------------------------------------------- */
:root {
--font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo",
"Malgun Gothic", "Noto Sans CJK KR", sans-serif;
}
/* Japanese: Kanji share the Han Unicode block with Chinese and CSS fallback
order is not affected by `<html lang>`, so promote a Japanese-first CJK chain
only for Japanese docs (`<html lang="ja">`). `[lang|="ja"]` is the BCP-47
language-range selector — matches exactly `ja` or `ja-<region>`, never
unrelated subtags like `jam`. Inter still leads for Latin. */
html[lang|="ja"] {
--font-sans: var(--font-inter), "Hiragino Sans", "Hiragino Kaku Gothic ProN",
"Yu Gothic", "YuGothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP",
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
}
/* ---------------------------------------------------------------------------
* Multica Docs — editorial visual identity (v2)
*

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import {
createContext,
use,
useContext,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
@@ -30,7 +30,7 @@ export function DocsLocaleProvider({
}
export function useDocsLocale(): Lang {
return use(DocsLocaleContext);
return useContext(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same

View File

@@ -1,127 +0,0 @@
---
title: エージェントの作成と構成
description: エージェントを作成するために必要な最小限のフィールドと、すべての任意設定 — システム指示、環境変数、公開範囲、同時実行制限、アーカイブ。
---
import { Callout } from "fumadocs-ui/components/callout";
[エージェント](/agents)を作成するのに必要なのは 2 つだけです。**名前**と **[AI コーディングツール](/providers)の選択**です。それ以外はすべて任意です — システム指示、モデル、環境変数、CLI 引数、公開範囲、同時実行制限 — デフォルト値でも問題なく動作します。まず動かしてから後で調整しましょう。すべてのフィールドはいつでも変更できます。
## エージェントを作成する
前提条件: 使用中のマシンにサポートされている [AI コーディングツール](/providers)が少なくとも 1 つインストールされておりClaude Code、Codex など)、[デーモン](/daemon-runtimes)が実行中であること。まだそこまで準備できていない場合は、[Cloud クイックスタート](/cloud-quickstart)または[セルフホストクイックスタート](/self-host-quickstart)から始めてください。
準備が整ったら、ワークスペースの **Agents** ページに移動して **+ New** をクリックするか、CLI を使用します。
```bash
multica agent create
```
このフォームには必須フィールドが 2 つだけあります。**name**(ワークスペース内で一意であること)と **runtime**= AI コーディングツールの選択)です。それ以外のすべてのフィールドは、以下でセクションごとに扱います。
## AI コーディングツールを選ぶ
各ランタイムは特定の AI コーディングツールを基盤としています。Multica はそのうち 12 個をサポートします。最も一般的な選択肢は次のとおりです。
| ツール | 適している場合 |
|---|---|
| **Claude Code** | Anthropic の公式ツールで、最も完成度の高い機能セットを提供します。**最初の選択として最適です** |
| **Codex** | OpenAI 製で、主流の代替手段です |
| **Cursor** | Cursor エディターのエコシステムを使うユーザー |
| **Copilot** | GitHub アカウントの権限を活用するチーム |
| **Gemini** | Google エコシステムのユーザー |
残りの 7 個Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClawと、各ツールの完全な機能比較表セッション再開、MCP、スキル注入パス、モデル選択は、[AI コーディングツール比較](/providers)で扱います。
## システム指示を書く
**システム指示**`instructions`)はすべてのタスクの先頭に追加され、エージェントがどんな役割を担い、どんなルールに従うべきかを伝えます。
```text
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
- Styling issues (tailwind class names, box model)
- Accessibility (a11y)
Don't change code — leave suggestions in a comment.
```
空のままにすると(デフォルト)、エージェントは追加の制約なしに、基盤となる AI コーディングツールのネイティブな動作を使用します。
## モデルを選ぶ
ほとんどの AI コーディングツールはモデル選択をサポートしています(例えば Claude Code では Sonnet と Opus のどちらかを選べます)。空のままにするとツール自体のデフォルト値が使われ、明示的に 1 つを選ぶとそのモデルが実行されます。各ツールがサポートするモデルは、[AI コーディングツール比較](/providers)にまとめられています。
モデルの変更は**新しいタスクにのみ適用されます**。すでにディスパッチされたタスクは、ディスパッチ時点で固定されたモデルで実行を続けます。
## カスタム環境変数 (custom_env)
**カスタム環境変数**`custom_env`)を使うと、タスク実行時に追加の環境変数を注入できます。代表的な用途は API キーの設定やアップストリームエンドポイントの切り替えです。
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
システムにとって重要な変数は上書きできません。`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`、そして `MULTICA_*` で始まるすべてのキーは、デーモンが静かに無視します(警告ログは残しますが、エラーは発生しません)。
<Callout type="warning">
**`custom_env` の値は Multica サーバーのデータベースに平文で保存されます。** エージェントの list/get レスポンスには環境変数の値がまったく含まれなくなり、不透明な個数だけが返されます。実際の値を読み取るには、ワークスペースの owner または admin が、専用で監査される `GET /api/agents/{id}/env` エンドポイントCLI: `multica agent env get <id>`)を呼び出す必要があります。タスクを実行中のエージェントは、ホストの owner 資格情報を使って他のエージェントの環境変数を明らかにすることはできません。このエンドポイントはエージェントアクターのセッションを拒否します。
**価値の高いシークレットは `custom_env` に入れないでください**本番データベースのパスワード、root レベルのトークンなど)。エージェントには**権限範囲が限定された専用の資格情報**(読み取り専用 API キー、単一スコープの PATを使用し、定期的にローテーションしてください。データベースのバックアップと DB 監査は、依然として意味のある露出面として残ります。
</Callout>
## カスタム CLI 引数 (custom_args)
**カスタム CLI 引数**`custom_args`は、AI コーディングツールのコマンドラインに 1 つずつ順に付け足される文字列配列です。
```json
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
```
最終的なコマンドは次のように生成されます。
```bash
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
```
引数はシェルを介さずそのまま渡されるため(注入リスクなし)、特定のフラグが認識されるかどうかは AI コーディングツール自体に依存します。この部分はツールによって大きな差があります。
<Callout type="tip">
`custom_env` と `custom_args` には厳格な上限はありませんが、実際には**それぞれ 10 個以内に抑えてください**。多すぎるとコマンドラインが長くなり、起動が遅くなり、メンテナンスも難しくなります。
</Callout>
## 公開範囲
- **ワークスペース**`workspace` — ワークスペースのすべてのメンバーが割り当てできます
- **非公開**`private` — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てできます
新しいエージェントはデフォルトで `private` です。
**非公開だからといって隠されるわけではありません** — すべてのメンバーが一覧で非公開エージェントの名前と説明を見ることができ、ただし機微な構成は読み取れません(環境変数の値はエージェントの list/get レスポンスに決して現れず、MCP 構成は owner 以外のユーザーにはマスキングされます)。詳しい意味は[エージェント → 誰がエージェントを割り当てられるか](/agents#who-can-assign-an-agent)を参照してください。
## 同時実行制限
**同時実行制限**`max_concurrent_tasks`)は、このエージェントが一度に並列で実行できるタスク数を制御します。デフォルト値は **6** です。上限に達した新しいタスクは拒否されず、キューで待機します。
これは 2 段階の制限のうち「エージェント層」にすぎません。デーモン自体がより広い上限(デフォルト値 20を適用し、2 つのうちより厳しい方が優先されます。詳しくは[デーモンとランタイム → 並列で何個のタスクを実行できるか](/daemon-runtimes#how-many-tasks-can-run-in-parallel)にあります。
この値を変更しても**すでに実行中のタスクはキャンセルされず**、次に処理されるタスクからのみ適用されます。
## ドメインの専門性をつなぐ: スキル
作成したエージェントには**スキル**をアタッチできます — タスク実行時に AI コーディングツールへ自動的に届けられる**ナレッジパック**`SKILL.md` + 補助ファイルです。新しいスキルを作成したり、GitHub または ClawHub からインポートしたり、マシン上の既存のスキルディレクトリからスキャンしたりできます。[スキル](/skills)を参照してください。
## アーカイブと復元
もう使わないエージェントは**アーカイブ**できます — 日常的な画面からは消えますが、履歴データ(実行したタスク、投稿したコメント)はすべてそのまま保持されます。いつでも**復元**して再び作業に投入できます。
<Callout type="warning">
**アーカイブは、そのエージェントに属する未完了のすべてのタスクを即座にキャンセルします** — 実行中、ディスパッチ済み、キュー待ちのタスクがすべて `cancelled` としてマークされ、続行されません。進行中の重要なタスクがある場合は、アーカイブする前に最後まで完了させてください。
</Callout>
アーカイブ済みのエージェントには新しいタスクを割り当てられません。
## 次のステップ
- [スキル](/skills) — エージェントにナレッジパックをアタッチする
- [AI コーディングツール比較](/providers) — 12 個のツール全体の機能比較表
- [エージェントへのイシューの割り当て](/assigning-issues) — 新しく作ったエージェントを作業に投入する

View File

@@ -1,127 +0,0 @@
---
title: 에이전트 생성 및 구성
description: 에이전트를 생성하는 데 필요한 최소 필드와 모든 선택적 설정 — 시스템 지침, 환경 변수, 공개 범위, 동시 실행 제한, 보관.
---
import { Callout } from "fumadocs-ui/components/callout";
[에이전트](/agents)를 생성하는 데는 단 두 가지만 필요합니다. **이름** 하나와 **[AI 코딩 도구](/providers) 선택** 하나입니다. 나머지는 모두 선택 사항입니다 — 시스템 지침, 모델, 환경 변수, CLI 인자, 공개 범위, 동시 실행 제한 — 기본값으로도 문제없이 작동합니다. 먼저 실행해 보고 나중에 조정하세요. 모든 필드는 언제든지 변경할 수 있습니다.
## 에이전트 생성
전제 조건: 사용 중인 기기에 지원되는 [AI 코딩 도구](/providers)가 최소 하나는 설치되어 있고(Claude Code, Codex 등) [데몬](/daemon-runtimes)이 실행 중이어야 합니다. 아직 여기까지 준비되지 않았다면 [Cloud 빠른 시작](/cloud-quickstart)이나 [자체 호스팅 빠른 시작](/self-host-quickstart)부터 시작하세요.
준비가 끝나면 워크스페이스의 **에이전트** 페이지로 이동해 **+ New**를 클릭하거나 CLI를 사용하세요.
```bash
multica agent create
```
이 폼에는 필수 필드가 두 개뿐입니다. **이름**(워크스페이스 내에서 고유해야 함)과 **런타임**(= AI 코딩 도구 선택)입니다. 나머지 모든 필드는 아래에서 섹션별로 다룹니다.
## AI 코딩 도구 선택
각 런타임은 특정 AI 코딩 도구를 기반으로 합니다. Multica는 그중 12개를 지원합니다. 가장 일반적인 선택지는 다음과 같습니다.
| 도구 | 적합한 경우 |
|---|---|
| **Claude Code** | Anthropic의 공식 도구로, 가장 완성도 높은 기능 집합을 제공합니다. **첫 선택으로 가장 좋습니다** |
| **Codex** | OpenAI 제품으로, 주류 대안입니다 |
| **Cursor** | Cursor 에디터 생태계 사용자 |
| **Copilot** | GitHub 계정 권한을 활용하는 팀 |
| **Gemini** | Google 생태계 사용자 |
나머지 7개(Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw)와 각 도구의 전체 기능 비교표(세션 재개, MCP, 스킬 주입 경로, 모델 선택)는 [AI 코딩 도구 비교](/providers)에서 다룹니다.
## 시스템 지침 작성
**시스템 지침**(`instructions`)은 모든 작업 앞에 추가되어, 에이전트가 어떤 역할을 맡고 어떤 규칙을 따라야 하는지 알려줍니다.
```text
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
- Styling issues (tailwind class names, box model)
- Accessibility (a11y)
Don't change code — leave suggestions in a comment.
```
비워 두면(기본값) 에이전트는 추가 제약 없이 기반이 되는 AI 코딩 도구의 기본 동작을 사용합니다.
## 모델 선택
대부분의 AI 코딩 도구는 모델 선택을 지원합니다(예를 들어 Claude Code는 Sonnet과 Opus 중에서 고를 수 있습니다). 비워 두면 도구 자체의 기본값이 사용되고, 명시적으로 하나를 선택하면 그 모델이 실행됩니다. 각 도구가 지원하는 모델은 [AI 코딩 도구 비교](/providers)에 정리되어 있습니다.
모델 변경은 **새 작업에만 적용됩니다**. 이미 디스패치된 작업은 디스패치 시점에 고정된 모델로 계속 실행됩니다.
## 사용자 지정 환경 변수 (custom_env)
**사용자 지정 환경 변수**(`custom_env`)를 사용하면 작업 실행 시점에 추가 환경 변수를 주입할 수 있습니다. 대표적인 용도는 API 키 설정이나 업스트림 엔드포인트 전환입니다.
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
시스템에 핵심적인 변수는 재정의할 수 없습니다. `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, 그리고 `MULTICA_*`로 시작하는 모든 키는 데몬이 조용히 무시합니다(경고 로그는 남기지만 오류는 발생하지 않습니다).
<Callout type="warning">
**`custom_env`의 값은 Multica 서버 데이터베이스에 평문으로 저장됩니다.** 에이전트 list/get 응답은 더 이상 환경 변수 값을 전혀 포함하지 않으며, 불투명한 개수만 반환합니다. 실제 값을 읽으려면 워크스페이스 owner 또는 admin이 전용으로 감사되는 `GET /api/agents/{id}/env` 엔드포인트(CLI: `multica agent env get <id>`)를 호출해야 합니다. 작업을 실행 중인 에이전트는 호스트의 owner 자격 증명을 이용해 다른 에이전트의 환경 변수를 드러낼 수 없습니다. 이 엔드포인트는 에이전트 액터 세션을 거부합니다.
**가치가 높은 secret은 `custom_env`에 넣지 마세요**(운영 데이터베이스 비밀번호, root 수준 토큰 등). 에이전트에는 **권한 범위가 제한된 전용 자격 증명**(읽기 전용 API 키, 단일 스코프 PAT)을 사용하고 정기적으로 교체하세요. 데이터베이스 백업과 DB 감사는 여전히 의미 있는 노출 표면으로 남아 있습니다.
</Callout>
## 사용자 지정 CLI 인자 (custom_args)
**사용자 지정 CLI 인자**(`custom_args`)는 AI 코딩 도구의 명령줄에 하나씩 차례로 덧붙는 문자열 배열입니다.
```json
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
```
최종 명령은 다음과 같이 만들어집니다.
```bash
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
```
인자는 셸을 거치지 않고 있는 그대로 전달되므로(주입 위험 없음), 특정 플래그가 인식되는지 여부는 AI 코딩 도구 자체에 달려 있습니다. 이 부분은 도구마다 상당한 차이가 있습니다.
<Callout type="tip">
`custom_env`와 `custom_args`에는 엄격한 상한이 없지만, 실제로는 **각각 10개 이내로 유지하세요**. 너무 많으면 명령줄이 길어지고 시작이 느려지며 유지 관리도 어려워집니다.
</Callout>
## 공개 범위
- **워크스페이스**(`workspace`) — 워크스페이스의 모든 멤버가 할당할 수 있습니다
- **비공개**(`private`) — 워크스페이스 owner, admin, 또는 에이전트 생성자만 할당할 수 있습니다
새 에이전트는 기본적으로 `private`입니다.
**비공개라고 해서 숨겨지는 것은 아닙니다** — 모든 멤버가 목록에서 비공개 에이전트의 이름과 설명을 볼 수 있으며, 다만 민감한 구성은 읽을 수 없습니다(환경 변수 값은 에이전트 list/get 응답에 절대 나타나지 않으며, MCP 구성은 owner가 아닌 사용자에게는 마스킹됩니다). 자세한 의미는 [에이전트 → 누가 에이전트를 할당할 수 있나요](/agents#who-can-assign-an-agent)를 참고하세요.
## 동시 실행 제한
**동시 실행 제한**(`max_concurrent_tasks`)은 이 에이전트가 한 번에 병렬로 실행할 수 있는 작업 수를 제어합니다. 기본값은 **6**입니다. 상한에 도달한 새 작업은 거부되지 않고 대기열에서 대기합니다.
이것은 두 단계 제한 중 "에이전트 계층"에 불과합니다. 데몬 자체가 더 넓은 상한(기본값 20)을 적용하며, 둘 중 더 빡빡한 쪽이 우선합니다. 자세한 내용은 [데몬과 런타임 → 병렬로 몇 개의 작업을 실행할 수 있나요](/daemon-runtimes#how-many-tasks-can-run-in-parallel)에 있습니다.
이 값을 변경해도 **이미 실행 중인 작업은 취소되지 않으며**, 다음에 처리될 작업부터만 적용됩니다.
## 도메인 전문성 연결: 스킬
생성된 에이전트에는 **스킬**을 연결할 수 있습니다 — 작업 실행 시점에 AI 코딩 도구로 자동 전달되는 **지식 팩**(`SKILL.md` + 보조 파일)입니다. 새 스킬을 만들거나, GitHub 또는 ClawHub에서 가져오거나, 기기에 있는 기존 스킬 디렉터리에서 스캔할 수 있습니다. [스킬](/skills)을 참고하세요.
## 보관 및 복원
더 이상 사용하지 않는 에이전트는 **보관**할 수 있습니다 — 일상적인 화면에서는 사라지지만, 이력 데이터(실행한 작업, 작성한 댓글)는 모두 그대로 유지됩니다. 언제든지 **복원**하여 다시 작업에 투입할 수 있습니다.
<Callout type="warning">
**보관은 해당 에이전트에 속한 완료되지 않은 모든 작업을 즉시 취소합니다** — 실행 중, 디스패치됨, 대기 중인 작업이 모두 `cancelled`로 표시되며 계속 진행되지 않습니다. 진행 중인 중요한 작업이 있다면 보관하기 전에 끝까지 완료되도록 두세요.
</Callout>
보관된 에이전트에는 새 작업을 할당할 수 없습니다.
## 다음 단계
- [스킬](/skills) — 에이전트에 지식 팩 연결하기
- [AI 코딩 도구 비교](/providers) — 12개 도구 전체의 기능 비교표
- [에이전트에게 이슈 할당하기](/assigning-issues) — 새로 만든 에이전트를 작업에 투입하기

View File

@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 12 of them
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
The other seven (Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
## Writing system instructions
@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
<Callout type="warning">
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
</Callout>
## Custom CLI arguments (custom_args)
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
New agents default to `private`.
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
## Concurrency limit
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
## Next steps
- [Skills](/skills) — attach knowledge packs to an agent
- [AI coding tools comparison](/providers) — full capability matrix across all 12 tools
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -21,7 +21,7 @@ multica agent create
## 选一款 AI 编程工具
运行时背后是一款具体的 AI 编程工具。Multica 支持 12 款,最常用的几款:
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
| 工具 | 适合 |
|---|---|
@@ -31,7 +31,7 @@ multica agent create
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 7 款(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
另外 6Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn不会报错
<Callout type="warning">
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
**不要把高价值 secret 放进 `custom_env`**生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT定期轮换。
</Callout>
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
新建默认 `private`。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置env 值从不会出现在 agent list/get 响应里MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
## 并发上限
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
- [AI 编程工具对照](/providers) —— 12 款工具的完整能力差别
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来

View File

@@ -1,49 +0,0 @@
---
title: エージェント
description: "エージェントは Multica ワークスペースの一級メンバーです — イシューを割り当てられ、コメントを投稿し、@ でメンションされることができます。人間との核心的な違いは、エージェントは自分から作業を始め、通知を受け取らない点です。"
---
import { Callout } from "fumadocs-ui/components/callout";
エージェントは Multica [ワークスペース](/workspaces)の **一級メンバー** です — 人間と同じように、[イシューを割り当てられ](/assigning-issues)、[コメント](/comments)で発言し、[`@` でメンションされ](/mentioning-agents)、[プロジェクト](/projects)をリードできます。核心的な違いはこれです。すべてのエージェントの背後には、あなたのマシンで動作する [AI コーディングツール](/providers)があります。エージェントにタスクを割り当てると、特に促さなくても **数秒以内に自分から作業を始めます** — 急かす必要も、オフラインになることもなく、24時間いつでも利用できます。
## エージェントができること
エージェントは人間と同じ「メンバー」の表面を使っており、UI ではほとんど区別されません。
- **[イシューを割り当てられる](/assigning-issues)** — 担当者に設定された瞬間、自動的に作業を始めます
- **[`@` でメンションされる](/mentioning-agents)** — コメントに `@agent-name` と書くと、目覚めてそのコメントを読みます
- **[コメント](/comments)を投稿する** — イシューの下で進捗を報告し、人々に返信します
- **[プロジェクト](/projects)をリードする** — 人間と同じように、プロジェクトリードに設定できます
- **自分で[イシュー](/issues)を開く** — タスクを実行している間に関連する問題を見つけると、直接新しいイシューを作成できます
協業ビューから見ると、エージェントはただのワークスペースのメンバーです — 人間と同じメンバー一覧に名前が並び、通常はその前に小さなロボットアイコンが付きます。
## 人間との違い
いくつかの重要な違いは、実際にエージェントを使い始めて初めて見えてきます。
- **自分から始めます** — イシューを割り当てたり `@` でメンションしたりすると、Multica が即座にそのタスクをエージェントのランタイムにディスパッチします。人間のようにメッセージを見て応答するまで待つことはありません。トリガーの詳細については、[エージェントにイシューを割り当てる](/assigning-issues)と[コメントでエージェントを @ メンションする](/mentioning-agents)を参照してください。
- **通知を受け取りません** — エージェントはあなたの[インボックス](/inbox)の向こう側に現れることは決してなく、`@all` の受信対象にも含まれません。エージェントは「メッセージを読む受信者」ではなく「タスクを実行するためにトリガーされる作業の単位」です。
- **1つの AI コーディングツールに紐づいています** — すべてのエージェントはランタイムに紐づいています(ランタイム = デーモン × 1つの AI コーディングツール。[デーモンとランタイム](/daemon-runtimes)を参照)。ツールがオフラインだとエージェントは作業できず、新しいタスクはランタイムが戻るまで待機します。
- **アーカイブできます** — もう使わないエージェントをアーカイブすると日常的なビューから消えます。いつでも好きなときに復元できます。アーカイブすると、現在実行中のタスクはすべてキャンセルされます。
## 誰がエージェントを割り当てられるか
エージェントを作成するとき、誰がそのエージェントをイシューに割り当てたりプロジェクトリードに設定したりできるかを制御する **可視性visibility** を選択します。
- **Workspace** — ワークスペースの任意のメンバーが割り当てられます
- **Private** — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てられます
新しいエージェントはデフォルトで **private** です。ワークスペース全体で利用できるようにするには、作成時に可視性を `workspace` に設定するか、後でエージェントの設定で変更してください。役割と権限の完全なマトリクスについては、[メンバーと役割](/members-roles)を参照してください。
<Callout type="info">
**private は「誰が割り当てられるかを制限する」という意味であって、「他の全員から隠す」という意味ではありません。** ワークスペースのすべてのメンバーは、エージェント一覧で private エージェントの名前と説明を見ることができます — 見えないのは設定の詳細だけですカスタム環境変数、MCP 設定、その他の機密フィールドはマスクされます。「1人だけに見える」ようにしたい場合、現時点では実現できません。
</Callout>
## 次のステップ
- [エージェントの作成と構成](/agents-create) — エージェントを作る方法
- [スキル](/skills) — エージェントに知識パックを添付する
- [スクワッド](/squads) — 適切なエージェントが適切なイシューを担当するよう、リーダーの下にエージェントをグループ化する
- [デーモンとランタイム](/daemon-runtimes) — エージェントが実際に動作するために必要なもの

View File

@@ -1,49 +0,0 @@
---
title: 에이전트
description: "에이전트는 Multica 워크스페이스의 일급 멤버입니다 — 이슈를 할당받고, 댓글을 달고, @로 멘션될 수 있습니다. 사람과의 핵심 차이는, 에이전트는 스스로 작업을 시작하며 알림을 받지 않는다는 점입니다."
---
import { Callout } from "fumadocs-ui/components/callout";
에이전트는 Multica [워크스페이스](/workspaces)의 **일급 멤버**입니다 — 사람과 마찬가지로 [이슈를 할당받고](/assigning-issues), [댓글](/comments)에서 발언하고, [`@`로 멘션되며](/mentioning-agents), [프로젝트](/projects)를 이끌 수 있습니다. 핵심 차이는 이것입니다. 모든 에이전트 뒤에는 여러분의 기기에서 실행되는 [AI 코딩 도구](/providers)가 있습니다. 에이전트에게 작업을 할당하면 별다른 재촉 없이 **수 초 내에 스스로 작업을 시작**합니다 — 닦달할 필요도, 오프라인이 되지도 않으며, 24시간 내내 가용합니다.
## 에이전트가 할 수 있는 일
에이전트는 사람과 동일한 "멤버" 표면을 사용하며, UI에서는 거의 구분되지 않습니다.
- **[이슈를 할당받기](/assigning-issues)** — 담당자로 지정되는 순간 자동으로 작업을 시작합니다
- **[`@`로 멘션되기](/mentioning-agents)** — 댓글에 `@agent-name`을 쓰면 깨어나 해당 댓글을 읽습니다
- **[댓글](/comments) 작성** — 이슈 아래에서 진행 상황을 보고하고 사람들에게 답글을 답니다
- **[프로젝트](/projects) 이끌기** — 사람과 마찬가지로 프로젝트 리더로 지정될 수 있습니다
- **스스로 [이슈](/issues) 열기** — 작업을 실행하는 동안 관련 문제를 발견하면 직접 새 이슈를 생성할 수 있습니다
협업 뷰에서 보면 에이전트는 그저 워크스페이스의 한 멤버일 뿐입니다 — 사람과 같은 멤버 목록에 이름이 자리하며, 보통 앞에 작은 로봇 아이콘이 붙습니다.
## 사람과 다른 점
몇 가지 핵심 차이는 실제로 에이전트를 사용하기 시작해야 비로소 드러납니다.
- **스스로 시작합니다** — 이슈를 할당하거나 `@`로 멘션하면 Multica가 즉시 해당 작업을 에이전트의 런타임에 디스패치합니다. 사람처럼 메시지를 보고 응답할 때까지 기다리지 않습니다. 트리거에 대한 자세한 내용은 [에이전트에게 이슈 할당하기](/assigning-issues)와 [댓글에서 에이전트 @-멘션하기](/mentioning-agents)를 참고하세요.
- **알림을 받지 않습니다** — 에이전트는 여러분의 [인박스](/inbox) 건너편에 결코 나타나지 않으며, `@all`의 수신 대상에도 포함되지 않습니다. 에이전트는 "메시지를 읽는 수신자"가 아니라 "작업을 실행하도록 트리거되는 작업 단위"입니다.
- **하나의 AI 코딩 도구에 묶여 있습니다** — 모든 에이전트는 런타임에 묶여 있습니다(런타임 = 데몬 × 하나의 AI 코딩 도구. [데몬과 런타임](/daemon-runtimes) 참고). 도구가 오프라인이면 에이전트는 작업할 수 없으며, 새 작업은 런타임이 돌아올 때까지 대기합니다.
- **보관할 수 있습니다** — 더 이상 사용하지 않는 에이전트를 보관하면 일상적인 뷰에서 사라지며, 원하면 언제든지 복원할 수 있습니다. 보관하면 현재 실행 중인 작업은 모두 취소됩니다.
## 누가 에이전트를 할당할 수 있나
에이전트를 생성할 때, 누가 그 에이전트를 이슈에 할당하거나 프로젝트 리더로 지정할 수 있는지를 제어하는 **가시성(visibility)** 을 선택합니다.
- **워크스페이스(Workspace)** — 워크스페이스의 모든 멤버가 할당할 수 있습니다
- **비공개(Private)** — 워크스페이스의 owner, admin, 또는 에이전트 생성자만 할당할 수 있습니다
새 에이전트는 기본적으로 **비공개**입니다. 전체 워크스페이스에서 사용할 수 있게 하려면, 생성 시 가시성을 `workspace`로 설정하거나 이후에 에이전트 설정에서 변경하세요. 전체 역할-권한 매트릭스는 [멤버와 역할](/members-roles)을 참고하세요.
<Callout type="info">
**비공개는 "누가 할당할 수 있는지를 제한"한다는 뜻이지, "다른 모든 사람에게 숨긴다"는 뜻이 아닙니다.** 워크스페이스의 모든 멤버는 에이전트 목록에서 비공개 에이전트의 이름과 설명을 볼 수 있습니다 — 단지 설정 세부 정보는 볼 수 없을 뿐입니다(사용자 정의 환경 변수, MCP 설정 및 기타 민감한 필드는 마스킹됩니다). "단 한 사람에게만 보이게" 하고 싶다면, 현재로서는 불가능합니다.
</Callout>
## 다음 단계
- [에이전트 생성 및 구성](/agents-create) — 에이전트를 만드는 방법
- [스킬](/skills) — 에이전트에 지식 팩 연결하기
- [스쿼드](/squads) — 적합한 에이전트가 적합한 이슈를 맡도록 리더 아래 에이전트를 그룹으로 묶기
- [데몬과 런타임](/daemon-runtimes) — 에이전트가 실제로 실행되기 위해 필요한 것

View File

@@ -1,83 +0,0 @@
---
title: エージェントにイシューを割り当てる
description: イシューをエージェントに渡すと、作業が終わるまで公式の担当者として引き継ぎます — 完全なコンテキストを持ち、イシューのステータスやフィールドを変更できます。
---
import { Callout } from "fumadocs-ui/components/callout";
[イシュー](/issues)を[エージェント](/agents)に割り当てると、作業が終わるまで**公式の担当者**として働きます — イシューの完全なコンテキスト(説明 + すべての[コメント](/comments))を読み、ステータスを変更し、コメントを投稿し、フィールドを編集できます。これは Multica の 4 つのトリガー経路の中で**最も一般的で、最も重い**方式です。同じフローは[スクワッド](/squads)を担当者として受け付けることもできます — その場合、Multica は代わりにスクワッドの**リーダーエージェント**をトリガーします。
| 経路 | 使う場面 | イシューの変更 | コンテキスト | 優先度 | 自動リトライ |
|---|---|---|---|---|---|
| **割り当て** | エージェントに所有権を渡す | 担当者を変更 | イシュー + すべてのコメント | イシューから継承 | ✓ |
| [**@メンション**](/mentioning-agents) | ちょっと見てもらうために呼び込む | 変更なし | イシュー + トリガーコメント | イシューから継承 | ✓ |
| [**チャット**](/chat) | イシューと無関係な 1 対 1 の会話 | イシューは関与しない | 現在の会話履歴 | 固定で medium | ✓ |
| [**オートパイロット**](/autopilots) | スケジュールまたは手動の自動化 | モードによる | モードによる | オートパイロットが設定 | ✗ |
「自動リトライ」とは、インフラ障害(ランタイムのオフライン、タイムアウト)後のリトライを指します。エージェント側のビジネスエラー(たとえばモデルがエラーを報告する場合)はリトライされません。詳しくは [**タスク**](/tasks)を参照してください。
## UI から割り当てる
イシュー詳細ページで、**担当者**ピッカーをクリックしてください。ワークスペースのすべてのメンバー、アーカイブされていないすべてのエージェント、アーカイブされていないすべての[スクワッド](/squads)が一覧表示されます。エージェント(またはスクワッド)を選ぶと、イシューはすぐに割り当てられます。
いくつかのルールがあります。
- **ワークスペースエージェント**はどのメンバーでも割り当てられます。**プライベートエージェント**はその owner またはワークスペースの admin のみが割り当てられます。
- **オンラインのランタイムを持つ**エージェントにのみ割り当てられます — 誰も実行していないエージェントはピッカーで利用不可と表示されます。
- イシューのステータスが **Backlog** のとき、割り当てても**エージェントはトリガーされません** — Backlog は一時保管所であり、イシューを Todo または In Progress に移して初めてエージェントがキューに入ります。
## CLI から割り当てる
コマンドラインでの同等の操作です。
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` はメンバーのユーザー名またはエージェント名(あいまい一致)を受け付けます。名前が重複するとき — たとえばエージェント `J` の隣に `Cursor - J` がある場合 — は、代わりに `--to-id <uuid>` を渡してください。このとき `multica workspace member list --output json` の `user_id`(メンバー)または `multica agent list --output json` の `id`エージェントを使います。UUID 一致は厳密かつ曖昧さがないため、スクリプトや CLI を駆動するエージェントに適しています。`--to` と `--to-id` は同時に使えません。
割り当て解除:
```bash
multica issue assign MUL-42 --unassign
```
## 割り当て後に起こること
Backlog ではないイシューがエージェントに割り当てられると、Multica はすぐにバックグラウンドで次のことを行います。
1. イシューから継承した優先度で `queued` 状態の `task` をキューに入れ、エージェントが存在するランタイムへルーティングします。
2. エージェントのデーモンが次のポーリング時に `task` を取得し、`dispatched` に遷移させます。
3. エージェントが作業を開始すると `task` が `running` に移ります。完了すると `completed` または `failed` になります。
4. 実行中、エージェントはイシューのステータスを変更し、コメントを投稿し、フィールドを編集できます — これらの操作はエージェントの ID で表示されます。
**エージェントがオフラインの場合**、`task` はキューで待機します — **5 分後に `runtime_offline` の理由でタイムアウトして失敗します**。リトライ可能なソース(割り当て、@メンション、チャットについては、Multica が自動的に再度キューに入れます。完全なリトライルールは [**タスク**](/tasks)を参照してください。
割り当てると、エージェントはイシューに自動的に購読されます — ただし Multica では**エージェントはインボックス通知を受け取りません**(メンバーのみ受け取ります)。この購読は内部的な記録管理にすぎず、ユーザーに見える副作用はありません。
## 再割り当てまたは割り当て解除
担当者をエージェント A からエージェント B に変更すると、
1. **A が進行中だったものはすべてキャンセルされます** — `queued`、`dispatched`、`running` 状態のすべての `task` が `cancelled` と表示されます。
2. **B にはすぐに新しい `task` がキューに入ります**(イシューが Backlog でなく、B にオンラインのランタイムがある場合)。
<Callout type="warning">
**再割り当てはこのイシューのすべてのアクティブな `task` をキャンセルします — 以前の担当者のものだけではありません。** 別のエージェントが @メンションによってこのイシューで作業中の場合、その `task` も一緒にキャンセルされます。現在のところ、単一のエージェントの `task` だけを個別にキャンセルする UI 操作はありません。
</Callout>
割り当て解除(`--unassign` またはピッカーで「none」を選択は、すべてのアクティブな `task` 項目を `cancelled` と表示し、**新しい項目をキューに入れません**。既存の購読は自動的にクリアされません — 以前の担当者は購読リストに残ります(ただし依然としてインボックス通知は受け取りません)。
## イシューごとエージェントごとにアクティブな `task` が 1 つだけの理由
**単一のエージェントは、同じイシューで任意の時点に最大 1 つの `queued` または `dispatched` の `task` しか持てません。** データベースレベルの一意インデックスとクレームロジックがこれを強制します — 重複したキュー登録と、同時実行が互いを上書きすることを防ぎます。
しかし**異なるエージェントは同じイシューで並列に作業できます** — たとえばエージェント A が担当者で、エージェント B が @メンションされた場合、2 つの `task` 項目がそれぞれ自分のランタイムで実行されながら共存できます。完全な直列・並列ルールは [**タスク**](/tasks)を参照してください。
## 次へ
- [**コメントでエージェントを @メンションする**](/mentioning-agents) — 担当者とステータスを変えない、より軽いトリガー
- [**スクワッド**](/squads) — エージェントのグループに割り当て、リーダーに誰が引き受けるかを決めさせる
- [**チャット**](/chat) — イシューと無関係な 1 対 1 の会話
- [**オートパイロット**](/autopilots) — エージェントがスケジュールに沿って自動的に作業を開始するようにする

View File

@@ -1,83 +0,0 @@
---
title: 에이전트에게 이슈 할당하기
description: 이슈를 에이전트에게 넘기면 작업이 끝날 때까지 공식 담당자로 인계받습니다 — 전체 컨텍스트를 갖고 이슈 상태와 필드를 변경할 수 있습니다.
---
import { Callout } from "fumadocs-ui/components/callout";
[이슈](/issues)를 [에이전트](/agents)에게 할당하면, 작업이 끝날 때까지 **공식 담당자**로서 일합니다 — 이슈의 전체 컨텍스트(설명 + 모든 [댓글](/comments))를 읽을 수 있고, 상태를 변경하고, 댓글을 남기고, 필드를 수정할 수 있습니다. 이것은 Multica의 네 가지 트리거 경로 중 **가장 일반적이고 가장 무거운** 방식입니다. 동일한 흐름은 [스쿼드](/squads)를 담당자로 받을 수도 있습니다 — 이 경우 Multica는 대신 스쿼드의 **리더 에이전트**를 트리거합니다.
| 경로 | 사용 시점 | 이슈 변경 | 컨텍스트 | 우선순위 | 자동 재시도 |
|---|---|---|---|---|---|
| **할당** | 에이전트에게 소유권을 넘김 | 담당자 변경 | 이슈 + 모든 댓글 | 이슈에서 상속 | ✓ |
| [**@-멘션**](/mentioning-agents) | 잠깐 살펴보도록 끌어들임 | 변경 없음 | 이슈 + 트리거 댓글 | 이슈에서 상속 | ✓ |
| [**채팅**](/chat) | 이슈와 무관한 일대일 대화 | 이슈 관여 없음 | 현재 대화 기록 | 고정 중간 | ✓ |
| [**오토파일럿**](/autopilots) | 예약 또는 수동 자동화 | 모드에 따라 다름 | 모드에 따라 다름 | 오토파일럿이 설정 | ✗ |
"자동 재시도"는 인프라 장애(런타임 오프라인, 타임아웃) 이후의 재시도를 의미합니다. 에이전트 쪽의 비즈니스 오류(예: 모델이 오류를 보고하는 경우)는 재시도되지 않습니다. 자세한 내용은 [**작업**](/tasks)을 참고하세요.
## UI에서 할당하기
이슈 상세 페이지에서 **담당자** 선택기를 클릭하세요. 워크스페이스의 모든 멤버, 보관되지 않은 모든 에이전트, 보관되지 않은 모든 [스쿼드](/squads)가 목록에 표시됩니다. 에이전트(또는 스쿼드)를 선택하면 이슈가 즉시 할당됩니다.
몇 가지 규칙이 있습니다.
- **워크스페이스 에이전트**는 어떤 멤버든 할당할 수 있습니다. **프라이빗 에이전트**는 owner 또는 워크스페이스 admin만 할당할 수 있습니다.
- **온라인 런타임이 있는** 에이전트에게만 할당할 수 있습니다 — 아무도 실행하고 있지 않은 에이전트는 선택기에서 사용 불가로 표시됩니다.
- 이슈 상태가 **백로그**일 때 할당하면 **에이전트가 트리거되지 않습니다** — 백로그는 임시 보관소이며, 이슈를 할 일 또는 진행 중으로 옮겨야만 에이전트가 대기열에 들어갑니다.
## CLI에서 할당하기
명령줄에서의 동등한 작업입니다.
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to`는 멤버 사용자 이름 또는 에이전트 이름(퍼지 매칭)을 받습니다. 이름이 겹칠 때 — 예를 들어 에이전트 `J` 옆에 `Cursor - J`가 있을 때 — 대신 `--to-id <uuid>`를 전달하세요. 이때 `multica workspace member list --output json`의 `user_id`(멤버) 또는 `multica agent list --output json`의 `id`(에이전트)를 사용합니다. UUID 매칭은 엄격하고 모호하지 않으므로, 스크립트나 CLI를 구동하는 에이전트에게 적합합니다. `--to`와 `--to-id`는 함께 쓸 수 없습니다.
할당 해제:
```bash
multica issue assign MUL-42 --unassign
```
## 할당 이후에 일어나는 일
백로그가 아닌 이슈가 에이전트에게 할당되면, Multica는 즉시 백그라운드에서 다음을 수행합니다.
1. 이슈에서 상속한 우선순위로 `queued` 상태의 `task`를 대기열에 넣고, 에이전트가 있는 런타임으로 라우팅합니다.
2. 에이전트의 데몬이 다음 폴링 시 `task`를 가져가 `dispatched`로 전환합니다.
3. 에이전트가 작업을 시작하면 `task`가 `running`으로 이동합니다. 완료되면 `completed` 또는 `failed`가 됩니다.
4. 실행 중에 에이전트는 이슈의 상태를 변경하고, 댓글을 남기고, 필드를 수정할 수 있습니다 — 이러한 동작은 에이전트의 신원으로 표시됩니다.
**에이전트가 오프라인인 경우**, `task`는 대기열에서 기다립니다 — **5분 후 `runtime_offline` 사유로 타임아웃되어 실패합니다**. 재시도 가능한 소스(할당, @-멘션, 채팅)에 대해서는 Multica가 자동으로 다시 대기열에 넣습니다. 전체 재시도 규칙은 [**작업**](/tasks)을 참고하세요.
할당하면 에이전트가 이슈에 자동으로 구독됩니다 — 다만 Multica에서는 **에이전트가 인박스 알림을 받지 않습니다**(멤버만 받습니다). 이 구독은 내부 기록 관리일 뿐이며 사용자에게 보이는 부작용은 없습니다.
## 재할당 또는 할당 해제
담당자를 에이전트 A에서 에이전트 B로 변경하면:
1. **A가 진행 중이던 모든 것이 취소됩니다** — `queued`, `dispatched`, `running` 상태의 모든 `task`가 `cancelled`로 표시됩니다.
2. **B에게 즉시 새 `task`가 대기열에 들어갑니다**(이슈가 백로그가 아니고 B에게 온라인 런타임이 있는 경우).
<Callout type="warning">
**재할당은 이 이슈의 모든 활성 `task`를 취소합니다 — 이전 담당자의 것만이 아닙니다.** 다른 에이전트가 @-멘션 때문에 이 이슈에서 작업 중이라면, 그 `task`도 함께 취소됩니다. 현재로서는 단일 에이전트의 `task`만 따로 취소하는 UI 동작이 없습니다.
</Callout>
할당 해제(`--unassign` 또는 선택기에서 "none" 선택)는 모든 활성 `task` 항목을 `cancelled`로 표시하며 **새 항목을 대기열에 넣지 않습니다**. 기존 구독은 자동으로 정리되지 않습니다 — 이전 담당자는 구독 목록에 남아 있습니다(다만 여전히 인박스 알림은 받지 않습니다).
## 이슈당 에이전트당 활성 `task`가 하나뿐인 이유
**단일 에이전트는 같은 이슈에서 어느 시점에든 최대 하나의 `queued` 또는 `dispatched` `task`만 가질 수 있습니다.** 데이터베이스 수준의 고유 인덱스와 클레임 로직이 이를 강제합니다 — 중복 대기열 등록과 동시 실행이 서로를 덮어쓰는 것을 방지합니다.
하지만 **서로 다른 에이전트는 같은 이슈에서 병렬로 작업할 수 있습니다** — 예를 들어 에이전트 A가 담당자이고 에이전트 B가 @-멘션된 경우, 두 `task` 항목이 각자의 런타임에서 실행되며 공존할 수 있습니다. 전체 직렬/동시 실행 규칙은 [**작업**](/tasks)을 참고하세요.
## 다음 단계
- [**댓글에서 에이전트를 @-멘션하기**](/mentioning-agents) — 담당자와 상태를 건드리지 않는 더 가벼운 트리거
- [**스쿼드**](/squads) — 에이전트 그룹에게 할당하고 리더가 누가 맡을지 결정하도록 함
- [**채팅**](/chat) — 이슈와 무관한 일대일 대화
- [**오토파일럿**](/autopilots) — 에이전트가 예약된 일정에 따라 자동으로 작업을 시작하도록 함

View File

@@ -1,166 +0,0 @@
---
title: ログインとサインアップの構成
description: メール + 認証コードログイン、Google OAuth、サインアップ許可リスト、ローカルテストコードを構成します。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica は 2 つのログイン方式をサポートしています。**メール + 認証コード**(デフォルト)と **Google OAuth**(オプション)です。ログインに成功すると、サーバーは 30 日間有効な JWT クッキーを発行します。このページでは、各方式の構成方法、誰がサインアップできるかを制限する方法、そしてセルフホストのデプロイで最も陥りやすい落とし穴を 1 つ取り上げます。
以下で参照する環境変数の一覧は[環境変数](/environment-variables)を参照してください。トークンの使い方とライフサイクルの詳細は[認証とトークン](/auth-tokens)を参照してください。
## メール + 認証コードログインの仕組み
ユーザーがログインページでメールを入力します → サーバーが 6 桁のコードを送信します → ユーザーがコードを入力します → サーバーがコードを検証します → JWT クッキーが発行されます。標準的なフローです。2 つの送信バックエンドがサポートされているので、デプロイ環境に合うほうを選んでください。
### オプション A: Resendクラウド / 公開インターネットのデプロイに推奨)
1. [Resend](https://resend.com/) アカウントを作成し、ドメインを認証します
2. API キーを作成します
3. 環境変数を設定します:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
```
4. サーバーを再起動します
### オプション B: SMTP relayセルフホスト / オンプレミスのデプロイ用)
デプロイ環境から `api.resend.com` に到達できない場合や、すでに内部メール relayMicrosoft Exchange、Postfix、オンプレミスの SendGrid など)がある場合に使用してください。両方が設定されている場合は `SMTP_HOST` が `RESEND_API_KEY` より優先されます。`SMTP_HOST` が空でなければ、`RESEND_API_KEY` も併せて構成されていても、サーバーは常に SMTP を経由するため、認証メールと招待メールが内部ネットワークの外に出ることは決してありません。
SMTP 経路は、ほとんどのオンプレミスメールサーバー(特に Microsoft Exchange の receive connectorが公開する 3 つの relay モードをサポートします。
| モード | ポート | 認証 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | なし — IP / サブネットで送信を信頼 | 伝送経路上はなし(内部セグメント専用) |
| 認証付き送信submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS、自動アップグレード |
| 暗黙的 TLSSMTPS | `465` | — | **まだサポートされていません** — ポート 25 または 587 を使用してください |
**ポート 25 の匿名 Exchange relay** — 認証情報なしで信頼されたサブネットからのメールを受け入れる、典型的な「internal SMTP relay」/ Exchange 匿名 receive connector:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**ポート 587 の認証付き送信** — サービスアカウントを必要とする relay 用。サーバーが STARTTLS のサポートを通知すると自動的にアップグレードされます:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
起動時に、サーバーは選択したプロバイダーを出力します。例えば `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(または `Resend API` / `DEV mode`)のように表示されます。パスワードがログに記録されることは決してありません。再起動後に SMTP の行が見えない場合は `SMTP_HOST` がプロセスに届いていないので、コンテナ環境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)を確認してください。
**どちらも設定しない場合**: サーバーはエラーを出しませんが、**送信されるはずだったすべてのメールがサーバーの stdout にのみ書き出されます**。ローカル開発には便利ですが(ログからコードをコピーできます)、プロダクションではブラックホールになります。
## 固定ローカルテストコード
<Callout type="warning">
**公開アクセス可能なインスタンスでは固定の認証コードを有効にしないでください。**
非プロダクションのインスタンスがデフォルトで `888888` を受け入れていた従来の動作は削除されました。明示的に構成しない限り、`888888` の入力は他の誤ったコードと同じように扱われます。
メールバックエンドをまったく構成していないResend も SMTP もない)ローカル開発では、サーバーログに出力される生成されたコードを使用してください。決定論的なローカル / プライベートの自動化が必要な場合は、`MULTICA_DEV_VERIFICATION_CODE` を `888888` のような 6 桁の値に設定し、`APP_ENV` を非プロダクションに保ってください:
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
このショートカットは `APP_ENV=production` のときは無視されます。
</Callout>
プロダクションのデプロイでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにし、`APP_ENV=production` に設定してください。`make selfhost` / `docker-compose.selfhost.yml` でデプロイする場合、`APP_ENV` はデフォルトで `production` です。
## Google OAuth の構成
オプションです。構成しないとメール + 認証コードのみが利用可能で、構成するとログインページに「Sign in with Google」ボタンが追加されます。
1. [Google Cloud Console](https://console.cloud.google.com/) で OAuth 2.0 クライアントを作成します
2. **Authorized redirect URIs** を Multica フロントエンドのアドレスに `/auth/callback` を加えた値に設定します。例:
```text
https://multica.yourdomain.com/auth/callback
```
3. クライアント ID とクライアント secret を取得したら、3 つの環境変数を設定します:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. サーバーを再起動します。
**ランタイムで反映されます**: フロントエンドは `/api/config` を通じてランタイムにこれらの設定を読み込みます — 変更後にサーバーを再起動すると、フロントエンドはリビルドや再デプロイなしで新しい値を取得します。
<Callout type="warning">
**リダイレクト URI は Google Console と `GOOGLE_REDIRECT_URI` の両方で完全に一致している必要があります** — プロトコル(`http` と `https`)、末尾のスラッシュ、ポートを含みます。少しでも一致しないと Google は OAuth フロー全体を拒否し、ユーザーに表示されるエラーは `redirect_uri_mismatch` です。
</Callout>
## 誰がサインアップできるかを制限する
3 つの環境変数が優先順位に従って組み合わされます。
<Mermaid chart={`
graph TD
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
A -- Yes --> Allow[Allow signup]
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
B -- Yes --> Allow
B -- No --> C{Any allowlist<br/>non-empty?}
C -- Yes --> Block[Reject]
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
D -- Yes --> Allow
D -- No --> Block
`} />
**既存のユーザーはいつでも再ログインできます** — サインアップ許可リストは**初回サインアップ**にのみ適用され、戻ってくるユーザーは妨げられません。
- **`ALLOWED_EMAILS`**(最高優先度) — 明示的なメール許可リスト、カンマ区切り。**空でない場合、リストにあるメールのみがサインアップできます。**
- **`ALLOWED_EMAIL_DOMAINS`** — ドメイン許可リスト、カンマ区切り(例: `company.io,partner.com`)。
- **`ALLOW_SIGNUP`** — マスタースイッチ、デフォルト `true`。`false` に設定するとサインアップが完全に無効になります。
<Callout type="warning">
**3 つの層は OR ではなく AND のセマンティクスです。** よくある誤った直感は、`ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` が「company.io に加えて他の全員を許可する」という意味だと考えることです。そうでは**ありません**。いずれかの層に空でない値があると、**それに一致しないメールはただちに拒否され**、`ALLOW_SIGNUP=true` はそれを無効にできません。
実際に「全員を許可」するには、3 つの変数をすべて空のままにしてください(または `ALLOW_SIGNUP=true` を維持してください)。
</Callout>
**典型的な構成**:
| 目的 | 構成 |
|---|---|
| 内部専用、`company.io` の従業員のみ | `ALLOWED_EMAIL_DOMAINS=company.io` |
| 内部 + 少数の外部コラボレーター | `ALLOWED_EMAIL_DOMAINS=company.io` + コラボレーターのアドレスを `ALLOWED_EMAILS` に追加 |
| セルフサービスのサインアップを完全に無効化、招待のみ | `ALLOW_SIGNUP=false` |
| 開放型サインアップ(プロダクションには非推奨) | 3 つすべて空 |
## サインアップを無効にしても人を招待できますか?
**すでに Multica アカウントを持っている人のみ可能です。** 招待の受諾はサインアップ許可リストをチェックしません — 招待された人がすでにサインアップ済み(例えば別のワークスペースで)であれば、招待リンクをクリックしてログインすれば受諾できます。
**しかし一度もサインアップしていない人は招待で救うことはできません。** 受諾する前にまずログインする必要があり、ログインの最初のステップ(認証コードの要求)はサインアップ許可リストのチェックを通過します。`ALLOW_SIGNUP=false` であるか、そのメールが `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` にない場合、**サインアップを完了できず**、したがって招待を受諾することもできません。
まだサインアップしていない外部コラボレーターを招待するには: そのメールを `ALLOWED_EMAILS` に一時的に追加し、その人がサインアップして招待を受諾するのを待ってから、エントリを削除してください。
招待の作成と使用方法については[メンバーとロール](/members-roles)を参照してください。
## 次に
- [環境変数](/environment-variables) — このページで使用するすべての変数の完全な定義
- [認証とトークン](/auth-tokens) — JWT / PAT / デーモントークンの分類と使い方
- [トラブルシューティング](/troubleshooting) — 認証コードが届かない、OAuth `redirect_uri_mismatch`、サインアップ拒否

View File

@@ -1,166 +0,0 @@
---
title: 로그인 및 회원가입 구성
description: 이메일 + 인증 코드 로그인, Google OAuth, 회원가입 허용 목록, 로컬 테스트 코드를 구성합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica는 두 가지 로그인 방식을 지원합니다. **이메일 + 인증 코드**(기본값)와 **Google OAuth**(선택). 로그인에 성공하면 서버가 30일 수명의 JWT 쿠키를 발급합니다. 이 페이지에서는 각 방식을 구성하는 방법, 누가 회원가입할 수 있는지 제한하는 방법, 그리고 자체 호스팅 배포에서 가장 빠지기 쉬운 함정 하나를 다룹니다.
아래에서 참조하는 환경 변수 목록은 [환경 변수](/environment-variables)를 참고하세요. 토큰 사용법과 수명 주기 세부 사항은 [인증 및 토큰](/auth-tokens)을 참고하세요.
## 이메일 + 인증 코드 로그인의 작동 방식
사용자가 로그인 페이지에서 이메일을 입력합니다 → 서버가 6자리 코드를 보냅니다 → 사용자가 코드를 입력합니다 → 서버가 코드를 검증합니다 → JWT 쿠키가 발급됩니다. 표준 흐름입니다. 두 가지 전송 백엔드가 지원되므로 배포 환경에 맞는 쪽을 선택하세요.
### 옵션 A: Resend (클라우드 / 공용 인터넷 배포에 권장)
1. [Resend](https://resend.com/) 계정을 만들고 도메인을 인증합니다
2. API 키를 생성합니다
3. 환경 변수를 설정합니다:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
```
4. 서버를 재시작합니다
### 옵션 B: SMTP relay (자체 호스팅 / 온프레미스 배포용)
배포 환경에서 `api.resend.com`에 접근할 수 없거나 이미 내부 메일 relay(Microsoft Exchange, Postfix, 온프레미스 SendGrid 등)가 있는 경우에 사용하세요. 둘 다 설정된 경우 `SMTP_HOST`가 `RESEND_API_KEY`보다 우선합니다. `SMTP_HOST`가 비어 있지 않으면 `RESEND_API_KEY`도 함께 구성되어 있더라도 서버는 항상 SMTP를 통하므로, 인증 및 초대 메일이 내부 네트워크를 벗어나는 일이 결코 없습니다.
SMTP 경로는 대부분의 온프레미스 메일 서버(특히 Microsoft Exchange의 receive connector)가 노출하는 세 가지 relay 모드를 지원합니다:
| 모드 | 포트 | 인증 | TLS |
|---|---|---|---|
| 익명 내부 relay | `25` | 없음 — IP / 서브넷으로 제출을 신뢰 | 전송 경로상 없음(내부 세그먼트 전용) |
| 인증된 제출(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, 자동 업그레이드 |
| 암묵적 TLS (SMTPS) | `465` | — | **아직 지원하지 않음** — 포트 25 또는 587을 사용하세요 |
**포트 25의 익명 Exchange relay** — 자격 증명 없이 신뢰된 서브넷에서 오는 메일을 받아들이는 일반적인 "internal SMTP relay" / Exchange 익명 receive connector:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**포트 587의 인증된 제출** — 서비스 계정이 필요한 relay용. 서버가 STARTTLS 지원을 알리면 자동으로 업그레이드됩니다:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
시작 시 서버는 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
**둘 다 설정하지 않으면**: 서버는 오류를 내지 않지만, **전송되어야 했던 모든 이메일이 서버의 stdout에만 기록됩니다**. 로컬 개발에는 편리하지만(로그에서 코드를 복사하면 됩니다), 프로덕션에서는 블랙홀이 됩니다.
## 고정 로컬 테스트 코드
<Callout type="warning">
**공용으로 접근 가능한 인스턴스에서는 고정 인증 코드를 활성화하지 마세요.**
프로덕션이 아닌 인스턴스가 기본적으로 `888888`을 받아들이던 기존 동작은 제거되었습니다. 명시적으로 구성하지 않는 한 `888888`을 입력하는 것은 다른 잘못된 코드와 동일하게 처리됩니다.
이메일 백엔드를 전혀 구성하지 않은(Resend도 SMTP도 없는) 로컬 개발에서는 서버 로그에 출력되는 생성된 코드를 사용해야 합니다. 결정적인 로컬/사설 자동화가 필요하다면 `MULTICA_DEV_VERIFICATION_CODE`를 `888888` 같은 6자리 값으로 설정하고 `APP_ENV`를 프로덕션이 아닌 값으로 유지하세요:
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
이 단축키는 `APP_ENV=production`일 때 무시됩니다.
</Callout>
프로덕션 배포에서는 `MULTICA_DEV_VERIFICATION_CODE`를 비워 두고 `APP_ENV=production`으로 설정해야 합니다. `make selfhost` / `docker-compose.selfhost.yml`로 배포하는 경우 `APP_ENV`는 기본적으로 `production`입니다.
## Google OAuth 구성
선택 사항입니다. 구성하지 않으면 이메일 + 인증 코드만 사용할 수 있고, 구성하면 로그인 페이지에 "Google로 로그인" 버튼이 추가됩니다.
1. [Google Cloud Console](https://console.cloud.google.com/)에서 OAuth 2.0 클라이언트를 생성합니다
2. **승인된 리디렉션 URI**(Authorized redirect URIs)를 Multica 프런트엔드 주소에 `/auth/callback`을 더한 값으로 설정합니다. 예:
```text
https://multica.yourdomain.com/auth/callback
```
3. 클라이언트 ID와 클라이언트 secret을 얻은 후 세 개의 환경 변수를 설정합니다:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. 서버를 재시작합니다.
**런타임에 적용됨**: 프런트엔드는 `/api/config`를 통해 런타임에 이 설정을 읽습니다. 변경 후 서버를 재시작하면 프런트엔드가 다시 빌드하거나 다시 배포할 필요 없이 새 값을 가져옵니다.
<Callout type="warning">
**리디렉션 URI는 Google Console과 `GOOGLE_REDIRECT_URI` 양쪽에서 정확히 일치해야 합니다** — 프로토콜(`http` vs `https`), 끝의 슬래시, 포트까지 포함합니다. 조금이라도 일치하지 않으면 Google이 전체 OAuth 흐름을 거부하며, 사용자에게 표시되는 오류는 `redirect_uri_mismatch`입니다.
</Callout>
## 누가 회원가입할 수 있는지 제한하기
세 개의 환경 변수가 우선순위에 따라 조합됩니다:
<Mermaid chart={`
graph TD
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
A -- Yes --> Allow[Allow signup]
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
B -- Yes --> Allow
B -- No --> C{Any allowlist<br/>non-empty?}
C -- Yes --> Block[Reject]
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
D -- Yes --> Allow
D -- No --> Block
`} />
**기존 사용자는 언제든 다시 로그인할 수 있습니다** — 회원가입 허용 목록은 **최초 회원가입**에만 적용되며, 돌아오는 사용자는 막지 않습니다.
- **`ALLOWED_EMAILS`** (최고 우선순위) — 명시적 이메일 허용 목록, 쉼표로 구분합니다. **비어 있지 않으면 목록에 있는 이메일만 회원가입할 수 있습니다.**
- **`ALLOWED_EMAIL_DOMAINS`** — 도메인 허용 목록, 쉼표로 구분합니다(예: `company.io,partner.com`).
- **`ALLOW_SIGNUP`** — 마스터 스위치, 기본값 `true`. `false`로 설정하면 회원가입이 완전히 비활성화됩니다.
<Callout type="warning">
**세 계층은 OR가 아니라 AND 의미입니다.** 흔한 잘못된 직관은 `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true`가 "company.io에 더해 다른 모든 사람을 허용"한다는 것입니다. 그렇지 **않습니다**. 어느 계층이든 비어 있지 않은 값이 있으면 **그에 일치하지 않는 이메일은 곧바로 거부되며**, `ALLOW_SIGNUP=true`는 그것을 무효로 만들지 못합니다.
실제로 "모두 허용"하려면 세 변수를 모두 비워 두세요(또는 `ALLOW_SIGNUP=true`를 유지하세요).
</Callout>
**일반적인 구성**:
| 목표 | 구성 |
|---|---|
| 내부 전용, `company.io` 직원만 | `ALLOWED_EMAIL_DOMAINS=company.io` |
| 내부 + 소수의 외부 협업자 | `ALLOWED_EMAIL_DOMAINS=company.io` + 협업자 주소를 `ALLOWED_EMAILS`에 추가 |
| 셀프서비스 회원가입을 완전히 비활성화, 초대 전용 | `ALLOW_SIGNUP=false` |
| 개방형 회원가입(프로덕션에는 권장하지 않음) | 셋 다 비움 |
## 회원가입을 비활성화해도 사람을 초대할 수 있나요?
**이미 Multica 계정이 있는 사람만 가능합니다.** 초대 수락은 회원가입 허용 목록을 확인하지 않습니다. 초대받은 사람이 이미 회원가입한 상태라면(예: 다른 워크스페이스에서), 초대 링크를 클릭하고 로그인하면 수락할 수 있습니다.
**하지만 한 번도 회원가입하지 않은 사람은 초대로 구제할 수 없습니다.** 수락하기 전에 먼저 로그인해야 하고, 로그인의 첫 단계(인증 코드 요청)는 회원가입 허용 목록 검사를 거칩니다. `ALLOW_SIGNUP=false`이거나 그들의 이메일이 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`에 없으면 **회원가입을 완료할 수 없으며**, 따라서 초대도 수락할 수 없습니다.
아직 회원가입하지 않은 외부 협업자를 초대하려면: 그들의 이메일을 `ALLOWED_EMAILS`에 임시로 추가하고, 그들이 회원가입하고 초대를 수락하기를 기다린 다음 항목을 제거하세요.
초대를 만들고 사용하는 방법은 [멤버 및 역할](/members-roles)을 참고하세요.
## 다음
- [환경 변수](/environment-variables) — 이 페이지에서 사용하는 모든 변수의 전체 정의
- [인증 및 토큰](/auth-tokens) — JWT / PAT / 데몬 토큰의 분류와 사용법
- [문제 해결](/troubleshooting) — 인증 코드 미수신, OAuth `redirect_uri_mismatch`, 회원가입 거부

View File

@@ -29,50 +29,18 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
| Mode | Port | Auth | TLS |
|---|---|---|---|
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
| Implicit TLS (SMTPS) | `465` | optional (`SMTP_USERNAME` + `SMTP_PASSWORD`) | TLS handshake on connect — auto-enabled on port `465`, or force on a non-standard port with `SMTP_TLS=implicit` |
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**Implicit TLS (SMTPS) on port 465** — for providers that only offer SMTPS and don't advertise STARTTLS (e.g. Aliyun / Tencent enterprise mail). Port `465` auto-enables implicit TLS; `SMTP_TLS=implicit` (aliases: `smtps`, `ssl`) forces it on a non-standard SMTPS port:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked, including the negotiated TLS mode — for example `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` or `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

View File

@@ -29,50 +29,18 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`:只要 `SMTP_HOST` 非空server 一律走 SMTP 路径,即便 `RESEND_API_KEY` 也配着,验证码和邀请邮件也不会经过 Resend 外发出内网
SMTP 路径覆盖大多数本地邮件服务器(特别是 Microsoft Exchange 的 receive connector暴露的三种 relay 模式:
| 模式 | 端口 | 认证 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | 无 —— 按 IP / 子网信任 | 链路上无 TLS仅限内网段 |
| 认证提交submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS自动升级 |
| 隐式 TLSSMTPS | `465` | 可选(`SMTP_USERNAME` + `SMTP_PASSWORD` | 连上即 TLS 握手 —— 端口 `465` 自动启用;非标准端口设 `SMTP_TLS=implicit` 强制开启 |
**匿名 Exchange relay端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector按可信子网放行不要求凭据
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
**认证提交,端口 587** —— 需要 service account 的 relay服务端 advertise STARTTLS 时会自动升级:
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
**隐式 TLSSMTPS端口 465** —— 适用于只提供 SMTPS、不广告 STARTTLS 的服务商(例如阿里云 / 腾讯企业邮箱)。端口 `465` 会自动启用隐式 TLS非标准 SMTPS 端口设置 `SMTP_TLS=implicit`(别名:`smtps`、`ssl`)即可强制开启:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # 465 自动启用隐式 TLS
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # 465 上可省略;在非标准 SMTPS 端口上必填
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
启动时 server 会打印当前选择的 provider 和协商出的 TLS 模式,比如 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 或 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。

View File

@@ -1,80 +0,0 @@
---
title: 認証とトークン
description: Multica には 3 種類のトークンがあります — ブラウザ、CLI、デーモンにそれぞれ 1 つずつ。どの場面でどれを使うかを解説します。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica には 3 種類のトークンがあり、それぞれが 1 つのコンテキストに対応します。ブラウザの Web UI、コマンドラインとスクリプト、そしてデーモンです。3 つとも同じあなたを表しますが、スコープと有効期間が異なります。
## 3 つのトークン
| トークン | 形式 | 使われる場所 | 有効期間 |
|---|---|---|---|
| **JWT クッキー** | `multica_auth` クッキー (HttpOnly) | Web ブラウザ | 30 日 |
| **個人アクセストークン (PAT)** | `mul_` プレフィックス | CLI、スクリプト、直接の API 呼び出し | デフォルトでは期限なし。API で作成する際に `expires_in_days` を渡せます |
| **デーモントークン** | `mdt_` プレフィックス | デーモンとサーバー間の通信 | デーモン自体が管理 |
日常的な利用では、最初の 2 つだけを直接扱うことになります。**[デーモン](/daemon-runtimes)トークン**は `multica daemon login` が自動的に作成・更新するため、気にする必要はありません。
## 各トークンがアクセスできるもの
| API ルート | JWT クッキー | PAT | デーモントークン |
|---|---|---|---|
| `/api/user/*` (ユーザーレベルの操作) | ✓ | ✓ | ✗ |
| `/api/workspaces/:id/*` (ワークスペースレベル) | ✓ | ✓ | ✗ |
| `/api/daemon/*` (デーモン専用) | ✗ | ✓ | ✓ |
| WebSocket `/ws` (リアルタイムプッシュ) | ✓ (クッキー) | ✓ (最初のメッセージで認証) | ✗ |
**PAT はほぼすべてにアクセスできます** — これは「完全なあなた」を表します。デーモントークンはデーモンに必要なこと、つまりタスクを取得して結果を報告することしかできません。
**どちらも `/api/daemon/*` にアクセスできますが、スコープが異なります。** PAT は**ユーザー全体**を表し、一度認証されると、あなたが所属するすべてのワークスペースを見ることができます。デーモントークンは作成時点で単一のワークスペースに固定され、そのワークスペースのリソースにしかアクセスできません。本番環境では、デーモンはデーモントークンで実行してください。手軽さのために PAT を使う近道を選ばないでください。そうしないと、デーモンに必要な以上にはるかに大きな権限を与えてしまいます。
## ログイン
### メール + 認証コード
1. メールアドレスを入力すると、サーバーが 6 桁のコードを送信します。
2. コードを入力すると、サーバーが JWT クッキーを発行ブラウザするか、PAT に交換CLIします。
<Callout type="warning">
**セルフホストの運用者は注意してください**: 公開デプロイでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにしておいてください。固定のローカルテストコードを有効にすると、`APP_ENV` が production 以外の間は、コードをリクエストできる人なら誰でもその値でサインインできてしまいます。[セルフホスト認証の構成](/auth-setup)を参照してください。
</Callout>
### Google OAuth
**Sign in with Google** をクリックして、標準の OAuth コールバックを通過してください。セルフホストには `GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET`、そしてリダイレクト URI を構成する必要があります — [セルフホスト認証の構成](/auth-setup)を参照してください。
## PAT の作成、表示、失効
PAT の**作成**は 2 つの方法で行えます。
- **Web UI**: 設定 → 個人アクセストークン → 新しいトークン
- **CLI**: `multica login` は、まだローカル PAT がない場合に自動的に 1 つ作成します
<Callout type="warning">
**完全な PAT は作成時に正確に 1 回だけ表示されます。** 更新したりダイアログを閉じたりした後は、二度と見ることができません。
Multica はデータベースに PAT のハッシュだけを保存します — サーバーでさえ元の値を取得できません。すぐにコピーして保存してください。紛失した場合の唯一の手段は、失効させて新しく作り直すことです。
</Callout>
既存の PAT の**表示**(名前、作成時刻、最終使用時刻 — 完全なトークンは**含みません**)は、設定 → 個人アクセストークンにあります。
PAT の**失効**: 一覧で Revoke をクリックしてください。失効はすぐに反映されます — その PAT で送られる次のリクエストは 401 で拒否されます。
## ログアウトはローカルトークンを削除するだけ
`multica auth logout` を実行するか、Web UI でログアウトをクリックすると、
- **ローカルトークンが消去されます** — CLI は `~/.multica/config.json` から PAT を削除し、ブラウザはクッキーを削除します。
- **PAT はサーバー上では依然として有効です** — ログアウトする前に誰かがあなたの PAT を入手していた場合(たとえば別のマシンにコピーしていた場合)、その人は**依然としてそれを使用できます**。
<Callout type="warning">
**PAT が漏洩したと疑われる場合は、単にログアウトするだけにしないでください。** 設定 → 個人アクセストークンに進み、そのトークンを**失効**させてください。失効だけが、漏洩したトークンを即座に無効化します。
</Callout>
## 次のステップ
- [CLI コマンドリファレンス](/cli) — すべての CLI コマンドの認証は自動です
- [セルフホスト認証の構成](/auth-setup) — セルフホスト時にメール、OAuth、サインアップ許可リストを構成する方法
- [デーモンとランタイム](/daemon-runtimes) — デーモントークンがどこから来るのか

View File

@@ -1,80 +0,0 @@
---
title: 인증과 토큰
description: Multica에는 세 종류의 토큰이 있습니다 — 브라우저, CLI, 데몬에 각각 하나씩. 어떤 상황에 무엇을 쓰는지 설명합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica에는 세 종류의 토큰이 있으며, 각각 하나의 컨텍스트에 대응합니다: 브라우저 Web UI, 명령줄과 스크립트, 그리고 데몬입니다. 세 가지 모두 동일한 당신을 나타내지만, 범위와 수명이 다릅니다.
## 세 가지 토큰
| 토큰 | 형식 | 사용되는 곳 | 수명 |
|---|---|---|---|
| **JWT 쿠키** | `multica_auth` 쿠키 (HttpOnly) | 웹 브라우저 | 30일 |
| **개인 액세스 토큰 (PAT)** | `mul_` 접두사 | CLI, 스크립트, 직접 API 호출 | 기본적으로 만료 없음. API로 생성할 때 `expires_in_days`를 전달할 수 있습니다 |
| **데몬 토큰** | `mdt_` 접두사 | 데몬-서버 통신 | 데몬 자체가 관리 |
일상적인 사용에서는 앞의 두 가지만 직접 다루게 됩니다. **[데몬](/daemon-runtimes) 토큰**은 `multica daemon login`이 자동으로 생성하고 갱신하므로, 신경 쓸 필요가 없습니다.
## 각 토큰이 접근할 수 있는 것
| API 라우트 | JWT 쿠키 | PAT | 데몬 토큰 |
|---|---|---|---|
| `/api/user/*` (사용자 수준 작업) | ✓ | ✓ | ✗ |
| `/api/workspaces/:id/*` (워크스페이스 수준) | ✓ | ✓ | ✗ |
| `/api/daemon/*` (데몬 전용) | ✗ | ✓ | ✓ |
| WebSocket `/ws` (실시간 푸시) | ✓ (쿠키) | ✓ (첫 메시지로 인증) | ✗ |
**PAT는 거의 모든 것에 접근할 수 있습니다** — 이는 "완전한 당신"을 나타냅니다. 데몬 토큰은 데몬에 필요한 일, 즉 작업을 가져오고 결과를 보고하는 것만 할 수 있습니다.
**둘 다 `/api/daemon/*`에 접근할 수 있지만, 범위가 다릅니다.** PAT는 **사용자 전체**를 나타내며 — 일단 인증되면 당신이 속한 모든 워크스페이스를 볼 수 있습니다. 데몬 토큰은 생성 시점에 단일 워크스페이스에 고정되며 해당 워크스페이스의 리소스에만 접근할 수 있습니다. 프로덕션에서는 데몬을 데몬 토큰으로 실행하세요. 편의를 위해 PAT를 사용하는 지름길을 택하지 마세요. 그렇지 않으면 데몬에 필요한 것보다 훨씬 많은 권한을 부여하게 됩니다.
## 로그인
### Email + 인증 코드
1. 이메일을 입력하면 서버가 6자리 코드를 보냅니다.
2. 코드를 입력하면 서버가 JWT 쿠키를 발급(브라우저)하거나 PAT로 교환(CLI)합니다.
<Callout type="warning">
**자체 호스팅 운영자는 주의하세요**: 공개 배포에서는 `MULTICA_DEV_VERIFICATION_CODE`를 비워 두세요. 고정된 로컬 테스트 코드를 활성화하면, `APP_ENV`가 production이 아닌 동안 코드를 요청할 수 있는 누구나 그 값으로 로그인할 수 있습니다. [자체 호스팅 인증 구성](/auth-setup)을 참고하세요.
</Callout>
### Google OAuth
**Sign in with Google**을 클릭하고 표준 OAuth 콜백을 거치세요. 자체 호스팅에는 `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, 그리고 리디렉션 URI를 구성해야 합니다 — [자체 호스팅 인증 구성](/auth-setup)을 참고하세요.
## PAT 생성, 조회, 취소
PAT **생성**은 두 가지 방법으로 할 수 있습니다:
- **Web UI**: 설정 → API 토큰 → 새 토큰
- **CLI**: `multica login`은 아직 로컬 PAT가 없으면 자동으로 하나를 생성합니다
<Callout type="warning">
**전체 PAT는 생성될 때 정확히 한 번만 표시됩니다.** 새로 고침하거나 대화상자를 닫은 후에는 다시 볼 수 없습니다.
Multica는 데이터베이스에 PAT의 해시만 저장합니다 — 서버조차 원본을 가져올 수 없습니다. 즉시 복사하여 저장하세요. 분실하면 유일한 방법은 취소하고 새로 생성하는 것입니다.
</Callout>
기존 PAT **조회**(이름, 생성 시각, 마지막 사용 시각 — 전체 토큰은 **포함하지 않음**)는 설정 → API 토큰에 있습니다.
PAT **취소**: 목록에서 Revoke를 클릭하세요. 취소는 즉시 적용됩니다 — 그 PAT로 보낸 다음 요청은 401로 거부됩니다.
## 로그아웃은 로컬 토큰만 삭제합니다
`multica auth logout`을 실행하거나 Web UI에서 로그아웃을 클릭하면:
- **로컬 토큰이 지워집니다** — CLI는 `~/.multica/config.json`에서 PAT를 제거하고, 브라우저는 쿠키를 삭제합니다.
- **PAT는 서버에서 여전히 유효합니다** — 로그아웃하기 전에 누군가 당신의 PAT를 입수했다면(예를 들어 다른 기기에 복사했다면), 그들은 **여전히 그것을 사용할 수 있습니다**.
<Callout type="warning">
**PAT가 유출되었다고 의심되면, 단순히 로그아웃하지 마세요.** 설정 → API 토큰로 가서 그 토큰을 **취소**하세요. 취소만이 유출된 토큰을 즉시 무효화합니다.
</Callout>
## 다음 단계
- [CLI 명령어 레퍼런스](/cli) — 모든 CLI 명령어의 인증은 자동입니다
- [자체 호스팅 인증 구성](/auth-setup) — 자체 호스팅 시 이메일, OAuth, 가입 허용 목록을 구성하는 방법
- [데몬과 런타임](/daemon-runtimes) — 데몬 토큰이 어디서 오는지

View File

@@ -1,239 +0,0 @@
---
title: オートパイロット
description: エージェントが cron スケジュールやインバウンド webhook で作業を開始したり、UI や CLI で一度だけ手動でトリガーしたりできるようにします。
---
import { Callout } from "fumadocs-ui/components/callout";
オートパイロットは、[エージェント](/agents)が**スケジュールに従って自動的に作業を開始**できるようにします — cron 式とタイムゾーンを設定すると、あなたが何もトリガーしなくても Multica が自ら [`task`](/tasks) をディスパッチします。定期点検、繰り返しのレポート、夜間のクリーンアップ作業など、「常設指示standing order」の形の作業に適しています。残りの 3 つのトリガー経路([割り当て](/assigning-issues)、[@-メンション](/mentioning-agents)、[チャット](/chat) — いずれもあなた自身が起点となる方式)と比べたとき、オートパイロットの核心的な違いは**時間駆動**であることです。
## オートパイロットを構成する
ワークスペースの**オートパイロット**ページで新しいオートパイロットを作成します。次の項目を設定します。
- **名前Name** — 表示名
- **エージェントAgent** — 実行をディスパッチする対象
- **優先度Priority** — 生成される `task` に継承されます(イシューの優先度と同じ意味)
- **説明 / プロンプトDescription / prompt** — 実行のたびにエージェントが受け取る作業説明
- **実行モードExecution mode** — 以下を参照
- **トリガーTriggers** — `schedule`cron + タイムゾーン)または `webhook` のうち少なくとも 1 つ
## 実行モードを選ぶ
オートパイロットには 2 つの実行モードがあります。**「イシュー作成」モードから始めてください。**
- **イシュー作成モードCreate issue mode**`create_issue` — デフォルトであり、**推奨**されます。各トリガーはまずワークスペースにイシューを作成し(タイトルには現在、単一のプレースホルダー `{{date}}` のみがサポートされ、これは `YYYY-MM-DD` 形式の UTC 日付に補間されます。それ以外の `{{...}}` トークンは作成時点で拒否されるため、タイプミスがイシュータイトルにリテラル文字列として静かに紛れ込むことを防ぎます)、通常の割り当てフローを通じてそのイシューをエージェントに割り当てます。すべての作業は、手動で割り当てたイシューと同じ履歴、コメント、ステータスを持った状態でイシューボードに上がります。
- **実行専用モードRun-only mode**`run_only` — イシュー作成をスキップし、`task` を直接キューに入れます。この実行はボードには表示されません — オートパイロットの実行履歴でのみ確認できます。
## スケジュールに従って実行する
すべてのオートパイロットには少なくとも 1 つの `schedule` トリガーが必要です。Cron は**標準の 5 フィールド形式**(分 時 日 月 曜日)を使用し、最小単位は **1 分**です(秒単位はありません)。タイムゾーンは IANA 形式(例: `Asia/Shanghai`で、cron 式がどのタイムゾーンで解釈されるかを決定します。
いくつかの例:
- `0 9 * * 1-5`, `Asia/Shanghai` — 平日の北京時間午前 9 時
- `*/30 * * * *`, `UTC` — 30 分ごと
- `0 3 * * *`, `UTC` — 毎日 UTC 午前 3 時
Multica サーバーは**30 秒**ごとに期限が来たトリガーをスキャンします — **実際の発火時刻は最大 30 秒まで遅れる可能性があり**、秒単位で正確ではありません。発火時刻のあたりでサーバーが再起動された場合、起動時に逃したトリガーを追いつきます(何も失われませんが、すぐに発火します)。
## 一度だけ手動でトリガーする
オートパイロットのデバッグ中に cron を待たないためには、手動でトリガーしてください。
- UI: オートパイロット詳細ページで「Run now」をクリック
- CLI:
```bash
multica autopilot trigger <autopilot-id>
```
手動トリガーは `schedule` トリガーとまったく同じ実行フローを通り — 実行レコードの `source` フィールドのみが `manual` とマークされます。
## webhook からトリガーする
オートパイロットはインバウンドの HTTP webhook でも発火できます。オートパイロット詳細ページで **Webhook** トリガーを追加すると、Multica は次のような形の一意の URL を生成します。
```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```
その URL に任意の JSON を POST してください — Multica は `source = webhook` で実行を記録し、本文を実行の `trigger_payload` として保存し、schedule トリガーとまったく同じ方法でエージェントをディスパッチします。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
**イシュー作成モード**では、インバウンドの payload が新しいイシューの説明に追記され、エージェントがインラインで読めるようになります。**実行専用モード**では、payload はデーモンがエージェントに渡す実行コンテキストの一部になります。
### Payload の形
独自のエンベロープenvelopeを送れます。
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
…または任意の JSON オブジェクト / 配列を送ることもできます。Multica はこれを内部エンベロープに正規化します。
```json
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
`event` フィールドを指定しない場合、Multica は一般的なヘッダーと本文フィールドからこれを推論します(`X-GitHub-Event` + 本文 `action`、`X-Gitlab-Event`、`X-Event-Type`、本文の `event`/`type`/`action`)。どれも一致しない場合、イベントは `webhook.received` になります。
GitHub のようなソースを構成するときは、content type を `application/json` に設定してください — フォームエンコードされた webhook payload は受け付けられません。
### イベントフィルター
新しい webhook トリガーはインバウンドの POST ごとに発火します。単一用途の URL には問題ありませんが、多数のイベントタイプをファンアウトするソースGitHub が代表的です — 単一のリポジトリ webhook 一つが `push`、`pull_request`、`workflow_run`、`check_suite` などを配信できますにはイズになります。webhook トリガーの**イベントフィルターEvent filters**セクションを使うと、実際に実行をディスパッチするイベントを制限でき、それ以外のすべては `status = ignored`、`reason = event_filtered` で配信履歴に記録され、実行もイシューも作成されません。
各行は 1 つのルールです。**イベント名event name**と、任意でカンマ区切りの **action** リストで構成されます。Multica は**いずれか 1 つ**の行でも一致すれば webhook を許可します。セクションを空のままにすると、すべてを受け付けます(フィルタリング以前の動作)。
例:
| イベント名 | Actions | 一致対象 |
| -------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | `action: completed` または `action: failed` の `workflow_run` イベントのみ |
| `workflow_run` | _(空)_ | action に関係なくすべての `workflow_run` イベント |
| `push` | _(空)_ | すべての `push` イベント |
#### イベント名と action の出所
Multica は次の順序でインバウンドリクエストから `event` 名と `action` を導き出します — **最初に一致したものが優先されます**。
**1. 本文エンベロープBody envelope。** 本文が文字列の `event` フィールドを持つ JSON オブジェクトであれば、その値がそのままイベント名になります。任意の `eventPayload` オブジェクトは、自身の `action` / `state` / `conclusion` / `status` フィールドから action 候補を提供します。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```
**2. ヘッダーHeaders。** 本文エンベロープがない場合、Multica は次のよく知られたプロバイダーヘッダーを読みます。
- `X-GitHub-Event: <event>` — (存在する場合)最上位の本文 `action` フィールドと組み合わされて `github.<event>.<action>` を形成します。
- `X-Gitlab-Event: <event>` — `gitlab.<event>` になります。
- `X-Event-Type: <event>` — そのまま通過します。
```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true
```
**3. 本文フォールバックBody fallback。** 本文エンベロープも既知のヘッダーもない場合、Multica は次の順序で最上位の本文文字列フィールドにフォールバックします: `event` → `type` → `action`。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```
**4. デフォルトDefault。** 上記のいずれも一致しない場合、イベントは `webhook.received` で、action 候補はありません。
**action 候補、全リスト。** イベントが決定されると、Multica は以下のすべての値を可能な action 一致対象として考慮します。
- イベントが `provider.event.<action>` の形のときのイベント名のサフィックス(例: `github.workflow_run.completed` → `completed`)。
- 本文フィールド `action`、`state`、`conclusion`、`status` — **JSON 文字列のときのみ該当します**。ブール値(`{"action": true}`)や数値は資格がないため、`event=trigger, action=true` を期待するフィルターは `{"trigger": true}` の本文とは決して一致しません。`true` は文字列ではなく bool だからです。
**よくある落とし穴。** `Event name: trigger` / `Actions: true` のようなフィルター行は、「本文に `trigger: true` があれば発火せよ」という意味では**ありません** — イベントフィルターは任意の本文フィールドではなく、*推論されたイベントと action* に一致させます。これにヒットさせるには、`X-Event-Type` で `trigger.true` を送るか(または上に示した本文エンベロープを使ってください)。保存されたフィルター行の周囲の空白(`" workflow_run "`)はそのまま保存され、決して一致しないため — 保存する前に trim してください。
#### クイックテスト
フィルターを構成したら、`curl` で両方の分岐を確認できます。
```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL は bearer secret です
生成された URL **そのものが**認証情報です。それを持っている人は誰でもオートパイロットを発火できます。トークンのように扱ってください。
- **公開のイシュースレッド、スクリーンショット、チャット履歴に貼り付けないでください。**
- **漏洩したら交換してください** — トリガー行で「Rotate URL」をクリックするか、`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>` を実行してください。古い URL はただちに動作を停止します。
- 強力なソース認証が必要なソースの場合は、トリガーごとの HMAC 署名検証を待ってください。この v1 URL は bearer 方式のみをサポートします。
- 現時点では、オートパイロットを閲覧できるワークスペースメンバーであればその webhook URL を読めます — 役割ごとのより厳格な secret の可視性は後続作業です。
### ステータスコードの意味
Multica は正常な no-op の結果に対して `status` フィールド付きで `200 OK` を返すため、プロバイダーの webhook 再試行メカニズムが URL を叩き続けることはありません。
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}` — 実行がディスパッチされました。
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}` — 割り当て先のランタイムがオフラインで、`skipped` の実行として記録されます。
- `{"status":"ignored","reason":"trigger_disabled"}` — トリガーが無効になっています。
- `{"status":"ignored","reason":"autopilot_paused"}` — オートパイロットが一時停止しています。
- `{"status":"ignored","reason":"autopilot_archived"}` — オートパイロットがアーカイブされています。
2xx 以外の応答は実際の失敗を扱います。
- `400` — 無効な JSON、スカラー本文、空の本文。
- `404` — 不明なトークン(`{"error":"webhook not found"}`)。
- `413` — payload が 256 KiB を超えました。
- `429` — トークンごとのレート制限超過(デフォルトは 60 req/min
### セルフホスト: 公開 URL を構成する
サーバーに `MULTICA_PUBLIC_URL` が設定されている場合(例: `https://multica.example.com`)、トリガー応答に絶対パスの `webhook_url` が含まれ、UI にはすぐにコピーできる URL が表示されます。設定しない場合、UI はクライアントの API origin から URL を構成します — デスクトップと同一オリジンの Web には問題ありませんが、カスタムのセルフホストリバースプロキシには適しません。Multica は、誤って構成されたリバースプロキシが攻撃者の制御するホストを指す webhook URL をサーバーに発行させて欺くことができないよう、`Host` / `X-Forwarded-Host` ヘッダーから公開ホストを導出しないよう意図的に設計されています。
## 実行履歴を見る
すべてのトリガーは**実行レコードrun record**を生成し、オートパイロット詳細ページの「History」タブで確認できます。
- トリガーソース(`schedule` / `manual` / `webhook`
- 開始時刻、完了時刻
- ステータス(`issue_created` / `running` / `completed` / `failed` / `skipped`
- 連携したイシュー(イシュー作成モード)または `task`(実行専用モード)
- 失敗理由(失敗またはスキップした場合)
## オートパイロットが失敗したらどうなるか
<Callout type="warning">
**オートパイロットの失敗は自動的に再試行されず、インボックス通知も送られません。** 失敗は実行履歴に `failed` のエントリを残すだけで — 割り当てや @-メンションのようなシステムレベルの再キューイングもなく、誰にも通知が行きません。オートパイロットが定期的な場合、**次の cron 発火が新しい実行をトリガー**しますが、失敗した作業が自動的に再実行されることはありません。
オートパイロットが重要な場合は、独自のモニタリングを設計してください — 例えば、エージェントに成功時にコメントを残させ、コメントの欠落に気づくことで失敗を検出する、といった具合です。
</Callout>
自動再試行がない理由: オートパイロットはすでに定期的であるため、システムレベルの再試行を追加すると次の予定実行の上に重なり、重複した実行を生み出します。スケジューリングを完全に cron に任せることで、すっきりと保てます。
## まだ提供されていない機能
**API 種類のトリガーはまだ接続されていません。** トリガースキーマは `api` 種類を予約していますが、それを発火させるイングレスルートはありません。UI は既存の行に Deprecated バッジを表示し、コピー / 交換の操作は提供しません。トリガーごとの HMAC 署名検証、IP 許可リスト、プロバイダー固有のイベントプリセットは後続作業として追跡されており、v1 URL は bearer 方式のみをサポートします。
## 次へ
- [**エージェントにイシューを割り当てる**](/assigning-issues) — イシューをエージェントに一回限りで引き渡す
- [**コメントでエージェントを @-メンションする**](/mentioning-agents) — コメントからエージェントを呼んで一度見てもらう
- [**チャット**](/chat) — イシューの外での一対一の会話

View File

@@ -1,239 +0,0 @@
---
title: 오토파일럿
description: 에이전트가 cron 스케줄, 인바운드 webhook으로 작업을 시작하거나, UI나 CLI로 한 번 수동 트리거하게 합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
오토파일럿은 [에이전트](/agents)가 **스케줄에 따라 자동으로 작업을 시작**하게 합니다 — cron 표현식과 타임존을 설정하면, 여러분이 아무것도 트리거하지 않아도 Multica가 알아서 [`task`](/tasks)를 디스패치합니다. 정기 점검, 반복 보고서, 야간 정리 작업 등 "상시 지시(standing order)" 형태의 작업에 잘 맞습니다. 나머지 세 가지 트리거 경로([할당](/assigning-issues), [@-멘션](/mentioning-agents), [채팅](/chat) — 모두 여러분이 직접 시작하는 방식)와 비교했을 때, 오토파일럿의 핵심 차이는 **시간 기반**이라는 점입니다.
## 오토파일럿 구성하기
워크스페이스의 **오토파일럿** 페이지에서 새 오토파일럿을 만듭니다. 다음 항목을 설정합니다.
- **이름(Name)** — 표시 이름
- **에이전트(Agent)** — 실행을 디스패치할 대상
- **우선순위(Priority)** — 생성되는 `task`에 상속됩니다(이슈 우선순위와 동일한 의미)
- **설명 / 프롬프트(Description / prompt)** — 매 실행마다 에이전트가 받는 작업 설명
- **실행 모드(Execution mode)** — 아래 참고
- **트리거(Triggers)** — `schedule`(cron + 타임존) 또는 `webhook` 중 최소 하나
## 실행 모드 선택하기
오토파일럿에는 두 가지 실행 모드가 있습니다. **"이슈 생성" 모드부터 시작하세요.**
- **이슈 생성 모드(Create issue mode)**(`create_issue`) — 기본값이며 **권장**됩니다. 각 트리거는 먼저 워크스페이스에 이슈를 생성한 다음(제목에는 현재 단일 플레이스홀더 `{{date}}` 하나만 지원되며, 이는 `YYYY-MM-DD` 형식의 UTC 날짜로 보간됩니다. 그 외의 `{{...}}` 토큰은 생성 시점에 거부되므로, 오타가 이슈 제목에 리터럴 문자열로 조용히 들어가는 일을 막습니다), 일반 할당 흐름을 통해 그 이슈를 에이전트에게 할당합니다. 모든 작업은 수동으로 할당한 이슈와 동일한 히스토리, 댓글, 상태를 가진 채 이슈 보드에 올라갑니다.
- **실행 전용 모드(Run-only mode)**(`run_only`) — 이슈 생성을 건너뛰고 `task`를 곧바로 대기열에 넣습니다. 이 실행은 보드에 표시되지 않으며 — 오토파일럿의 실행 히스토리에서만 확인할 수 있습니다.
## 스케줄에 따라 실행하기
모든 오토파일럿에는 최소 하나의 `schedule` 트리거가 필요합니다. Cron은 **표준 5필드 형식**(분 시 일 월 요일)을 사용하며, 최소 단위는 **1분**입니다(초 단위는 없음). 타임존은 IANA 형식(예: `Asia/Shanghai`)이며, cron 표현식이 어느 타임존으로 해석될지를 결정합니다.
몇 가지 예시입니다.
- `0 9 * * 1-5`, `Asia/Shanghai` — 평일 베이징 시간 오전 9시
- `*/30 * * * *`, `UTC` — 30분마다
- `0 3 * * *`, `UTC` — 매일 UTC 오전 3시
Multica 서버는 **30초**마다 만료된 트리거를 스캔합니다 — **실제 발화 시각은 최대 30초까지 지연될 수 있으며**, 초 단위로 정확하지는 않습니다. 발화 시각 즈음에 서버가 재시작되면, 시작 시 놓친 트리거를 따라잡습니다(아무것도 유실되지 않지만 즉시 발화됩니다).
## 수동으로 한 번 트리거하기
오토파일럿을 디버깅하는 동안 cron을 기다리지 않으려면 수동으로 트리거하세요.
- UI: 오토파일럿 상세 페이지에서 "Run now" 클릭
- CLI:
```bash
multica autopilot trigger <autopilot-id>
```
수동 트리거는 `schedule` 트리거와 완전히 동일한 실행 흐름을 거치며 — 실행 레코드의 `source` 필드만 `manual`로 표시됩니다.
## Webhook으로 트리거하기
오토파일럿은 인바운드 HTTP webhook으로도 발화할 수 있습니다. 오토파일럿 상세 페이지에서 **Webhook** 트리거를 추가하면, Multica가 다음과 같은 형태의 고유 URL을 생성합니다.
```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```
그 URL로 아무 JSON이나 POST하세요 — Multica는 `source = webhook`로 실행을 기록하고, 본문을 실행의 `trigger_payload`로 저장한 다음, schedule 트리거와 정확히 동일한 방식으로 에이전트를 디스패치합니다.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
**이슈 생성 모드**에서는 인바운드 payload가 새 이슈의 설명에 덧붙여져 에이전트가 인라인으로 읽을 수 있습니다. **실행 전용 모드**에서는 payload가 데몬이 에이전트에게 넘기는 실행 컨텍스트의 일부가 됩니다.
### Payload 형태
직접 만든 봉투(envelope)를 보낼 수 있습니다.
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
…또는 임의의 JSON 객체/배열을 보낼 수도 있습니다. Multica는 이를 내부 봉투로 정규화합니다.
```json
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
`event` 필드를 제공하지 않으면, Multica는 일반적인 헤더와 본문 필드로부터 이를 추론합니다(`X-GitHub-Event` + 본문 `action`, `X-Gitlab-Event`, `X-Event-Type`, 본문의 `event`/`type`/`action`). 어느 것도 일치하지 않으면 이벤트는 `webhook.received`가 됩니다.
GitHub 같은 소스를 구성할 때는 content type을 `application/json`으로 설정하세요 — 폼 인코딩된 webhook payload는 허용되지 않습니다.
### 이벤트 필터
새 webhook 트리거는 인바운드 POST마다 발화합니다. 단일 용도 URL에는 괜찮지만, 여러 이벤트 타입을 팬아웃하는 소스(GitHub가 대표적입니다 — 단일 저장소 webhook 하나가 `push`, `pull_request`, `workflow_run`, `check_suite` 등을 전달할 수 있습니다)에는 시끄럽습니다. webhook 트리거의 **이벤트 필터(Event filters)** 섹션을 사용하면 실제로 실행을 디스패치할 이벤트를 제한할 수 있으며, 그 외의 모든 것은 `status = ignored`, `reason = event_filtered`로 전달 히스토리에 기록되고 실행이나 이슈는 생성되지 않습니다.
각 행은 하나의 규칙입니다. **이벤트 이름(event name)**과 선택적으로 쉼표로 구분한 **action** 목록으로 구성됩니다. Multica는 **어느 한** 행이라도 일치하면 webhook을 허용합니다. 섹션을 비워 두면 모든 것을 수락합니다(필터링 이전 동작).
예시:
| 이벤트 이름 | Actions | 일치 대상 |
| -------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | `action: completed` 또는 `action: failed`인 `workflow_run` 이벤트만 |
| `workflow_run` | _(비어 있음)_ | action에 상관없이 모든 `workflow_run` 이벤트 |
| `push` | _(비어 있음)_ | 모든 `push` 이벤트 |
#### 이벤트 이름과 action의 출처
Multica는 다음 순서로 인바운드 요청에서 `event` 이름과 `action`을 도출하며 — **첫 번째로 일치하는 것이 우선합니다**.
**1. 본문 봉투(Body envelope).** 본문이 문자열 `event` 필드를 가진 JSON 객체이면, 그 값이 곧 이벤트 이름입니다. 선택적인 `eventPayload` 객체는 자신의 `action` / `state` / `conclusion` / `status` 필드에서 action 후보를 제공합니다.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```
**2. 헤더(Headers).** 본문 봉투가 없을 때, Multica는 다음의 잘 알려진 제공자 헤더를 읽습니다.
- `X-GitHub-Event: <event>` — (있는 경우) 최상위 본문 `action` 필드와 결합해 `github.<event>.<action>`을 형성합니다.
- `X-Gitlab-Event: <event>` — `gitlab.<event>`가 됩니다.
- `X-Event-Type: <event>` — 그대로 통과합니다.
```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true
```
**3. 본문 폴백(Body fallback).** 본문 봉투도 알려진 헤더도 없으면, Multica는 다음 순서로 최상위 본문 문자열 필드로 폴백합니다: `event` → `type` → `action`.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```
**4. 기본값(Default).** 위 어느 것도 일치하지 않으면, 이벤트는 `webhook.received`이고 action 후보는 없습니다.
**action 후보, 전체 목록.** 이벤트가 결정되면, Multica는 아래의 모든 값을 가능한 action 일치 대상으로 고려합니다.
- 이벤트가 `provider.event.<action>` 형태일 때의 이벤트 이름 접미사(예: `github.workflow_run.completed` → `completed`).
- 본문 필드 `action`, `state`, `conclusion`, `status` — **JSON 문자열일 때만 해당됩니다**. 불리언(`{"action": true}`)이나 숫자는 자격이 없으므로, `event=trigger, action=true`를 기대하는 필터는 `{"trigger": true}` 본문과 절대 일치하지 않습니다. `true`는 문자열이 아니라 bool이기 때문입니다.
**흔한 함정.** `Event name: trigger` / `Actions: true` 같은 필터 행은 "본문에 `trigger: true`가 있으면 발화하라"는 뜻이 **아닙니다** — 이벤트 필터는 임의의 본문 필드가 아니라 *추론된 이벤트와 action*에 일치시킵니다. 이를 적중시키려면 `X-Event-Type`로 `trigger.true`를 보내거나(또는 위에 표시된 본문 봉투를 사용하세요). 저장된 필터 행에서 주변 공백(`" workflow_run "`)은 그대로 저장되어 절대 일치하지 않으므로 — 저장하기 전에 trim하세요.
#### 빠른 테스트
필터를 구성한 후에는 `curl`로 두 분기를 모두 확인할 수 있습니다.
```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL은 bearer secret입니다
생성된 URL **자체가** 자격 증명입니다. 그것을 가진 사람은 누구나 오토파일럿을 발화할 수 있습니다. 토큰처럼 다루세요.
- **공개된 이슈 스레드, 스크린샷, 채팅 히스토리에 붙여넣지 마세요.**
- **유출되면 교체하세요** — 트리거 행에서 "Rotate URL"을 클릭하거나 `multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`를 실행하세요. 기존 URL은 즉시 작동을 멈춥니다.
- 강력한 소스 인증이 필요한 소스의 경우, 트리거별 HMAC 서명 검증을 기다리세요. 이 v1 URL은 bearer 방식만 지원합니다.
- 현재로서는 오토파일럿을 볼 수 있는 워크스페이스 멤버라면 그 webhook URL을 읽을 수 있습니다 — 역할별로 더 엄격한 secret 가시성은 후속 작업입니다.
### 상태 코드 의미
Multica는 정상적인 no-op 결과에 대해 `status` 필드와 함께 `200 OK`를 반환하므로, 제공자의 webhook 재시도 메커니즘이 URL을 계속 두드리지 않습니다.
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}` — 실행이 디스패치되었습니다.
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}` — 수신 대상의 런타임이 오프라인이며, `skipped` 실행으로 기록됩니다.
- `{"status":"ignored","reason":"trigger_disabled"}` — 트리거가 비활성화되어 있습니다.
- `{"status":"ignored","reason":"autopilot_paused"}` — 오토파일럿이 일시 정지되어 있습니다.
- `{"status":"ignored","reason":"autopilot_archived"}` — 오토파일럿이 보관되어 있습니다.
2xx가 아닌 응답은 실제 실패를 다룹니다.
- `400` — 유효하지 않은 JSON, 스칼라 본문, 빈 본문.
- `404` — 알 수 없는 토큰(`{"error":"webhook not found"}`).
- `413` — payload가 256 KiB를 초과했습니다.
- `429` — 토큰별 속도 제한 초과(기본값 60 req/min).
### 자체 호스팅: 공개 URL 구성하기
서버에 `MULTICA_PUBLIC_URL`이 설정되어 있으면(예: `https://multica.example.com`), 트리거 응답에 절대 경로 `webhook_url`이 포함되고 UI에는 복사 가능한 URL이 바로 표시됩니다. 설정하지 않으면 UI는 클라이언트의 API origin으로부터 URL을 구성합니다 — 데스크톱과 동일 출처 웹에는 괜찮지만, 커스텀 자체 호스팅 리버스 프록시에는 적합하지 않습니다. Multica는 잘못 구성된 리버스 프록시가 공격자가 제어하는 호스트를 가리키는 webhook URL을 서버가 발행하도록 속이지 못하게, `Host` / `X-Forwarded-Host` 헤더로부터 공개 호스트를 도출하지 않도록 의도적으로 설계되었습니다.
## 실행 히스토리 보기
모든 트리거는 **실행 레코드(run record)**를 생성하며, 오토파일럿 상세 페이지의 "History" 탭에서 볼 수 있습니다.
- 트리거 소스(`schedule` / `manual` / `webhook`)
- 시작 시각, 완료 시각
- 상태(`issue_created` / `running` / `completed` / `failed` / `skipped`)
- 연결된 이슈(이슈 생성 모드) 또는 `task`(실행 전용 모드)
- 실패 사유(실패하거나 건너뛴 경우)
## 오토파일럿이 실패하면 어떻게 되나요
<Callout type="warning">
**오토파일럿 실패는 자동으로 재시도되지 않으며 인박스 알림도 보내지 않습니다.** 실패는 실행 히스토리에 `failed` 항목을 남길 뿐 — 할당이나 @-멘션처럼 시스템 수준에서 다시 대기열에 넣는 일도 없고, 누구에게도 알림이 가지 않습니다. 오토파일럿이 주기적이라면 **다음 cron 발화가 새 실행을 트리거**하지만, 실패한 작업이 자동으로 다시 실행되지는 않습니다.
오토파일럿이 중요하다면 직접 모니터링을 설계하세요 — 예를 들어 에이전트가 성공 시 댓글을 남기게 하고, 댓글이 누락된 것을 알아채 실패를 잡아내는 식입니다.
</Callout>
자동 재시도가 없는 이유: 오토파일럿은 이미 주기적이므로, 시스템 수준의 재시도를 추가하면 다음 예약된 실행 위에 겹쳐 중복 실행을 만들어 냅니다. 스케줄링을 전적으로 cron에 맡기면 깔끔하게 유지됩니다.
## 아직 제공되지 않는 기능
**API 종류의 트리거는 아직 연결되어 있지 않습니다.** 트리거 스키마는 `api` 종류를 예약해 두었지만, 그것을 발화하는 인그레스 라우트가 없습니다. UI는 기존 행에 Deprecated 배지를 표시하며 복사/교체 기능을 제공하지 않습니다. 트리거별 HMAC 서명 검증, IP 허용 목록, 제공자별 이벤트 프리셋은 후속 작업으로 추적되고 있으며, v1 URL은 bearer 방식만 지원합니다.
## 다음
- [**에이전트에게 이슈 할당하기**](/assigning-issues) — 이슈를 에이전트에게 일회성으로 넘기기
- [**댓글에서 에이전트 @-멘션하기**](/mentioning-agents) — 댓글에서 에이전트를 불러 한번 살펴보게 하기
- [**채팅**](/chat) — 이슈 밖에서의 일대일 대화

View File

@@ -99,126 +99,6 @@ nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### Event filters
A new webhook trigger fires on every inbound POST, which is fine for a
single-purpose URL but noisy for sources that fan out many event types
(GitHub being the obvious one — a single repo webhook can deliver
`push`, `pull_request`, `workflow_run`, `check_suite`, and more). The
**Event filters** section on a webhook trigger lets you restrict which
events actually dispatch a run; everything else is recorded in delivery
history with `status = ignored` and `reason = event_filtered`, and no
run or issue is created.
Each row is one rule: an **event name** plus an optional
comma-separated **actions** list. Multica allows a webhook if **any**
row matches; leave the section empty to accept everything (the
pre-filter behavior).
Examples:
| Event name | Actions | Matches |
| -------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | `workflow_run` events with `action: completed` or `action: failed` only |
| `workflow_run` | _(empty)_ | every `workflow_run` event, regardless of action |
| `push` | _(empty)_ | every `push` event |
#### Where the event name and action come from
Multica derives the `event` name and `action` from the inbound request
in this order — **the first match wins**.
**1. Body envelope.** If the body is a JSON object with a string
`event` field, that value is the event name directly. An optional
`eventPayload` object then supplies action candidates from its
`action` / `state` / `conclusion` / `status` fields.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```
**2. Headers.** When no body envelope is present, Multica reads the
following well-known provider headers:
- `X-GitHub-Event: <event>` — combined with the top-level body
`action` field (when present) to form `github.<event>.<action>`.
- `X-Gitlab-Event: <event>` — becomes `gitlab.<event>`.
- `X-Event-Type: <event>` — passed through verbatim.
```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true
```
**3. Body fallback.** If neither a body envelope nor a known header is
present, Multica falls back to top-level body string fields in this
order: `event` → `type` → `action`.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```
**4. Default.** If nothing above matches, the event is
`webhook.received` and there are no action candidates.
**Action candidates, in full.** Once the event is determined, Multica
considers every value below as a possible action match:
- The event-name suffix, when the event has the form
`provider.event.<action>` (e.g. `github.workflow_run.completed` →
`completed`).
- The body fields `action`, `state`, `conclusion`, and `status` —
**only when they are JSON strings**. A boolean (`{"action": true}`)
or a number does not qualify, so a filter expecting
`event=trigger, action=true` will never match a body of
`{"trigger": true}` because `true` is a bool, not a string.
**Common gotcha.** A filter row like `Event name: trigger` /
`Actions: true` does **not** mean "fire when the body has
`trigger: true`" — Event filters match the *inferred event and
action*, not arbitrary body fields. Send `trigger.true` via
`X-Event-Type` (or use the body envelope shown above) to hit it.
Surrounding whitespace in saved filter rows (`" workflow_run "`) is
stored verbatim and will never match — trim before saving.
#### Quick test
Once a filter is configured, you can confirm both branches with `curl`:
```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the

View File

@@ -98,116 +98,6 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### 事件过滤
新建的 webhook 触发器对每一条入站 POST 都会触发,这在单一用途的 URL
上没问题,但对那种会扇出很多事件类型的来源(典型就是 GitHub——
一个仓库 webhook 就会同时下发 `push`、`pull_request`、`workflow_run`、
`check_suite` 等等就会很吵。webhook 触发器上的**事件过滤**区块用来
限制哪些事件真正派发一次 run其它的只记录到投递历史里
`status = ignored`、`reason = event_filtered`,不会建任何 issue 或 run。
每一行是一条规则:一个**事件名**加可选的、逗号分隔的 **action** 列表。
**任意一行命中**即放行;区块留空则接受所有事件(即过滤前的行为)。
例子:
| 事件名 | Actions | 命中范围 |
| ---------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | 只有 `action` 为 `completed` 或 `failed` 的 `workflow_run` 事件 |
| `workflow_run` | _(留空)_ | 所有 `workflow_run` 事件,不限 action |
| `push` | _(留空)_ | 所有 `push` 事件 |
#### 事件名和 action 从哪来
Multica 按下面的顺序从入站请求里推断 `event` 和 `action`**先命中先用**。
**1. Body envelope。** 如果 body 是一个 JSON 对象、且带字符串字段
`event`,就直接用它作为事件名。可选的 `eventPayload` 对象再
从自己的 `action` / `state` / `conclusion` / `status` 字段里提供
action 候选。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# 推断结果event = triggeraction 候选 = true
```
**2. 请求头。** 没有 body envelope 时按以下头部识别:
- `X-GitHub-Event: <event>` —— 结合 body 顶层的 `action` 字段
(如果有),拼成 `github.<event>.<action>`。
- `X-Gitlab-Event: <event>` —— 拼成 `gitlab.<event>`。
- `X-Event-Type: <event>` —— 原样使用。
```bash
# GitHub 风格:事件名来自 headeraction 来自 body。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# 推断结果event = github.workflow_run.completed
# → 命中 workflow_run / completed 的过滤规则
# 通用 event-type 头部 —— 不需要任何 body 字段。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# 推断结果event = trigger.true → 命中 trigger / true
```
**3. Body 兜底。** Body envelope 和上面的 header 都没有时,
从 body 顶层字符串字段里依次找:`event` → `type` → `action`。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# 推断结果event = trigger取自 `type`action 候选 = true
```
**4. 默认值。** 以上都没命中时,事件名取 `webhook.received`
没有 action 候选。
**action 候选的完整清单。** 事件名确定后,下面这些值都会被列为
可能的 action
- 事件名后缀,当事件形如 `provider.event.<action>` 时
(例如 `github.workflow_run.completed` → `completed`)。
- body 里 `action`、`state`、`conclusion`、`status` 这四个字段——
**必须是 JSON 字符串**。布尔(`{"action": true}`)或数字都不算
候选,所以 `event=trigger, action=true` 的过滤规则**永远命中不了**
`{"trigger": true}` 这种 body因为 `true` 是 bool 不是字符串。
**常见误区。** 一条 `Event name: trigger` / `Actions: true` 的规则
**不是**「body 里出现 `trigger: true` 就放行」的意思——事件过滤匹配的是
**推断出来的事件和 action**,不是任意 body 字段。要命中它,请用
`X-Event-Type` 头发送 `trigger.true`,或者用上面的 body envelope。
保存时带空格的值(例如 `" workflow_run "`)会被原样保存,但永远命中
不了——保存前请先 trim。
#### 快速验证
配好过滤后,可以用 `curl` 同时验证「命中」和「被过滤」两条路径:
```bash
# 命中 —— 请求头给出 event=workflow_runbody 给出 action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# 被过滤 —— 同样的事件,但 action 不在白名单
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:

View File

@@ -1,63 +0,0 @@
---
title: チャット
description: どのイシューにも属さない、エージェントとの一対一の会話 — 完全にサンドボックス化されています。エージェントはイシューを見たり変更したりできず、他の誰もこの会話を見ることはできません。
---
import { Callout } from "fumadocs-ui/components/callout";
**チャットはあなたと[エージェント](/agents)との一対一の会話です** — [イシュー](/issues)ボードから外に出るものです。エージェントはどのイシューも見られず、どのイシューも変更できず、会話全体は**完全に非公開**です([ワークスペース](/workspaces)内の他の誰も、admin を含めて、この会話を見ることはできません)。エージェントとアプローチを議論したり、ブレインストーミングをしたり、どのイシューにも属さない質問をしたりするのに適しています。
## エージェントを @-メンションするだけではだめなのですか?
[@-メンション](/mentioning-agents)はエージェントをイシューのコンテキスト**の中へ引き入れます** — エージェントはイシューの説明とすべての過去のコメントを読み、イシューを変更できます。チャットはこれを逆転させます。**あなたをイシューの外へ引き出します** — エージェントはこの単一の会話のみを見られ、どのイシューの存在も認識せず、イシューを変更する入口もありません。
2 つの判断基準:
- 特定のイシューのコンテキストに基づくフィードバックがほしいとき → [@-メンション](/mentioning-agents)
- どのイシューとも無関係なトピックを議論したいとき(または他の誰にも議論を見られたくないとき) → チャット
## 会話を始める
サイドバーから**チャット**を開き、エージェントを選んで、新しい会話を始めてください。インターフェースはどのメッセージングアプリとも似ています。メッセージを送るとエージェントが返信します。各メッセージはバックグラウンドで実行をトリガーするため(キューに入れられた `task`)、返信には数秒かかることがあります。
## チャットでエージェントができることとできないこと
エージェントは会話の中で**完全にサンドボックス化された**モードで実行されます。
**できること:**
- 現在のメッセージに含まれる質問に答える
- 構成された[スキル](/skills)と MCP を使う
- 自身の作業ディレクトリでファイルを読み書きする
- イシューコンテキストを必要としない `multica` CLI コマンドを呼び出す(例: 基本的なワークスペース情報の照会)
**できないこと:**
- **どのイシューも見ること** — エージェントが受け取るプロンプトにはイシュー ID がなく、`multica issue list` のようなコマンドは空の結果を返します
- **どのイシューも変更すること** — イシューコンテキストがなければ、権限チェックによって API 呼び出しがブロックされます
- **他の会話を見ること** — 会話は完全に隔離されています
- **誰かや任意のエージェントを @-メンションすること** — チャットは他者に通知する経路のない非公開の空間です
## 複数ターンのコンテキストが保持される仕組み
チャットは**プロバイダーセッションの再開**を通じて複数ターンのコンテキストを維持します — エージェントは最初の返信でプロバイダーセッションを確立し(例: Claude セッション)、そのセッション ID が保存されます。次のメッセージでは、タスクのディスパッチがその ID を渡し直すため、エージェントは毎回履歴を読み直すことなく**中断したところから再開**します。
もし**1 つのターンが失敗した**場合、Multica はセッション ID を確立していた以前のタスク(そのタスクが成功したか失敗したかにかかわらず)を探し、再開を試みます — 途中で一度失敗したからといって、会話全体の記憶が失われることはありません。
注: すべてのプロバイダーが実際にセッション再開を実装しているわけではありません — サポート状況は[**プロバイダーマトリックス**](/providers)を参照してください。
## 会話をアーカイブする
もう見たくない会話はアーカイブできます — 会話一覧で右クリックするか、詳細ページの「アーカイブ」ボタンを使ってください。アーカイブ後は次のようになります。
- 会話がアクティブな一覧から消えます(「アーカイブ済み」ビューで引き続き見つけられます)
- 過去のメッセージ、セッション ID、作業ディレクトリはすべて保持されます — 何も削除されません
<Callout type="warning">
**アーカイブ後には「復元」ボタンがありません。** 現在、アーカイブされた会話を再びアクティブな状態に戻す入口はありません。後でそのスレッドを続けたい場合は、新しい会話を始める必要があります。アーカイブされた会話の内容を再び見るには、「アーカイブ済み」ビューを開いて履歴を読んでください。
</Callout>
## 次へ
- [**オートパイロット**](/autopilots) — エージェントがスケジュールに従って自動的に作業を開始できるようにします
- [**エージェントにイシューを割り当てる**](/assigning-issues) — トピックをイシューボードに戻します

View File

@@ -1,63 +0,0 @@
---
title: 채팅
description: 어떤 이슈에도 속하지 않는, 에이전트와의 일대일 대화 — 완전히 샌드박스화되어 있습니다. 에이전트는 이슈를 보거나 변경할 수 없으며, 다른 누구도 이 대화를 볼 수 없습니다.
---
import { Callout } from "fumadocs-ui/components/callout";
**채팅은 당신과 [에이전트](/agents) 사이의 일대일 대화입니다** — [이슈](/issues) 보드에서 벗어나는 것입니다. 에이전트는 어떤 이슈도 보지 못하고 어떤 이슈도 변경할 수 없으며, 대화 전체가 **완전히 비공개**입니다([워크스페이스](/workspaces) 내의 다른 누구도, admin을 포함해서, 이 대화를 볼 수 없습니다). 에이전트와 접근 방식을 논의하거나, 브레인스토밍을 하거나, 어떤 이슈에도 속하지 않는 질문을 하기에 적합합니다.
## 그냥 에이전트를 @-멘션하면 안 되나요?
[@-멘션](/mentioning-agents)은 에이전트를 이슈의 컨텍스트 **안으로 끌어들입니다** — 에이전트는 이슈 설명과 모든 과거 댓글을 읽고, 이슈를 변경할 수 있습니다. 채팅은 이를 뒤집습니다. **당신을 이슈 밖으로 끌어냅니다** — 에이전트는 이 단일 대화만 볼 수 있고, 어떤 이슈의 존재도 인지하지 못하며, 이슈를 수정할 진입점도 없습니다.
두 가지 판단 기준:
- 특정 이슈의 컨텍스트에 기반한 피드백을 원할 때 → [@-멘션](/mentioning-agents)
- 어떤 이슈와도 무관한 주제를 논의하고 싶을 때(또는 다른 누구에게도 논의를 보이고 싶지 않을 때) → 채팅
## 대화 시작하기
사이드바에서 **채팅**을 열고, 에이전트를 선택한 다음, 새 대화를 시작하세요. 인터페이스는 여느 메시징 앱과 비슷합니다. 메시지를 보내면 에이전트가 답장합니다. 각 메시지는 백그라운드에서 실행을 트리거하므로(대기열에 들어간 `task`), 답장에는 몇 초가 걸릴 수 있습니다.
## 채팅에서 에이전트가 할 수 있는 일과 할 수 없는 일
에이전트는 대화 안에서 **완전히 샌드박스화된** 모드로 실행됩니다.
**할 수 있는 일:**
- 현재 메시지에 담긴 질문에 답하기
- 구성된 [스킬](/skills)과 MCP 사용하기
- 자신의 작업 디렉터리에서 파일 읽기 및 쓰기
- 이슈 컨텍스트가 필요 없는 `multica` CLI 명령 호출하기(예: 기본 워크스페이스 정보 조회)
**할 수 없는 일:**
- **어떤 이슈도 보기** — 에이전트가 받는 프롬프트에는 이슈 ID가 없으며, `multica issue list` 같은 명령은 빈 결과를 반환합니다
- **어떤 이슈도 변경하기** — 이슈 컨텍스트가 없으면 권한 검사에 의해 API 호출이 차단됩니다
- **다른 대화 보기** — 대화는 완전히 격리되어 있습니다
- **누구도, 어떤 에이전트도 @-멘션하기** — 채팅은 다른 사람에게 알릴 경로가 없는 비공개 공간입니다
## 여러 턴에 걸친 컨텍스트가 보존되는 방식
채팅은 **제공자 세션 재개**를 통해 여러 턴에 걸친 컨텍스트를 유지합니다 — 에이전트는 첫 답장에서 제공자 세션을 설정하고(예: Claude 세션), 그 세션 ID가 저장됩니다. 다음 메시지에서는 작업 디스패치가 그 ID를 다시 전달하므로, 에이전트는 매번 기록을 다시 읽지 않고도 **중단했던 지점부터 이어서 재개**합니다.
만약 **한 턴이 실패하면**, Multica는 세션 ID를 설정했던 이전 작업(그 작업이 성공했든 실패했든)을 찾아 재개를 시도합니다 — 중간에 한 번 실패한다고 해서 대화 전체의 기억이 사라지지는 않습니다.
참고: 모든 제공자가 실제로 세션 재개를 구현하는 것은 아닙니다 — 지원 현황은 [**제공자 매트릭스**](/providers)를 참고하세요.
## 대화 보관하기
더 이상 보고 싶지 않은 대화는 보관할 수 있습니다 — 대화 목록에서 우클릭하거나 상세 페이지의 "보관" 버튼을 사용하세요. 보관 후에는:
- 대화가 활성 목록에서 사라집니다("보관됨" 보기에서 여전히 찾을 수 있습니다)
- 과거 메시지, 세션 ID, 작업 디렉터리가 모두 보존됩니다 — 아무것도 삭제되지 않습니다
<Callout type="warning">
**보관 후에는 "복원" 버튼이 없습니다.** 현재 보관된 대화를 다시 활성 상태로 되돌리는 진입점이 없습니다. 나중에 그 스레드를 계속 이어가고 싶다면 새 대화를 시작해야 합니다. 보관된 대화의 내용을 다시 보려면 "보관됨" 보기를 열고 기록을 읽어 보세요.
</Callout>
## 다음
- [**오토파일럿**](/autopilots) — 에이전트가 일정에 따라 자동으로 작업을 시작하도록 하세요
- [**에이전트에게 이슈 할당하기**](/assigning-issues) — 주제를 이슈 보드로 다시 가져오세요

View File

@@ -1,147 +0,0 @@
---
title: CLI コマンドリファレンス
description: すべてのトップレベル Multica CLI コマンドを 1 ページにまとめた概要です。完全な使い方は `multica <command> --help` を実行してください。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica CLI は、Web UI でできるほぼすべての操作をそのまま提供します([イシュー](/issues)の作成、[エージェント](/agents)の割り当て、[デーモン](/daemon-runtimes)の起動など)。このページでは、すべてのトップレベルコマンドを 1 行の説明とともに一覧します。フラグや例の完全な一覧は `multica <command> --help` を実行してください。
## 認証する
CLI を初めて使うときにこのコマンドを実行して、**パーソナルアクセストークンPAT**を取得します。
```bash
multica login
```
ブラウザが自動的に開きます。Web アプリで承認すると、CLI が PAT`mul_` プレフィックス付き)を `~/.multica/config.json` に保存します。これ以降のすべてのコマンドはこの PAT で認証されます。
<Callout type="tip">
CI やヘッドレス環境では、ブラウザフローをスキップできます。Web アプリの **Settings → Personal Access Tokens** で PAT を作成し、`multica login --token <mul_...>` で直接渡してください。
</Callout>
トークンの種類による違いについては、[認証とトークン](/auth-tokens)を参照してください。
## 認証とセットアップ
| コマンド | 用途 |
|---|---|
| `multica login` | ログインして PAT を保存 |
| `multica auth status` | 現在のログイン状態、ユーザー、ワークスペースを表示 |
| `multica auth logout` | ローカルの PAT を削除 |
| `multica setup cloud` | Multica Cloud のワンショットセットアップ(ログイン + デーモンのインストール) |
| `multica setup self-host` | セルフホストバックエンドのワンショットセットアップ |
## ワークスペースとメンバー
| コマンド | 用途 |
|---|---|
| `multica workspace list` | アクセスできるすべてのワークスペースを一覧 |
| `multica workspace get <slug>` | 1 つのワークスペースの詳細を表示 |
| `multica workspace member list` | 現在のワークスペースのメンバーを一覧 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | ワークスペースのメタデータを更新admin/owner。長いフィールドは `--description-stdin` / `--context-stdin` を使用できます。 |
## イシューとプロジェクト
<Callout type="info">
`list` 系のコマンド(`multica issue list`、`autopilot list`、`project list` など)は、デフォルトで短く**そのままコピー&ペーストできる** ID を出力します。イシューは `MUL-123` のようなイシューキー、それ以外のリソースは短い UUID プレフィックスです。以下の後続コマンドの `<id>` 引数は短い ID と完全な UUID のどちらも受け取るため、一般的な流れは `multica issue list` → キーをコピー → `multica issue get MUL-123` となります。正式な UUID が必要なときは `list` コマンドに `--full-id` を渡してください。
</Callout>
| コマンド | 用途 |
|---|---|
| `multica issue list` | イシューを一覧(コピー&ペーストできるイシューキーを出力) |
| `multica issue get <id>` | 単一のイシューを表示(イシューキーまたは UUID を受け取る) |
| `multica issue create --title "..."` | 新しいイシューを作成 |
| `multica issue update <id> ...` | イシューを更新(ステータス、優先度、担当者など) |
| `multica issue assign <id> --agent <slug>` | エージェントに割り当て(即座にタスクをトリガー) |
| `multica issue status <id> --set <status>` | ステータス変更のショートカット |
| `multica issue search <query>` | キーワード検索 |
| `multica issue runs <id>` | イシュー上のエージェント実行を表示 |
| `multica issue rerun <id>` | イシューの現在のエージェント担当者向けに新しいタスクを再キューイング |
| `multica issue comment <id> ...` | ネスト: コメントの表示 / 投稿 |
| `multica issue subscriber <id> ...` | ネスト: 購読 / 購読解除 |
| `multica project list/get/create/update/delete/status` | プロジェクトの CRUD |
## エージェントとスキル
| コマンド | 用途 |
|---|---|
| `multica agent list` | ワークスペースのエージェントを一覧 |
| `multica agent get <slug>` | エージェントの構成を表示 |
| `multica agent create ...` | エージェントを作成 |
| `multica agent update <slug> ...` | エージェントを更新 |
| `multica agent archive <slug>` | アーカイブ |
| `multica agent restore <slug>` | アーカイブ済みのエージェントを復元 |
| `multica agent tasks <slug>` | エージェントのタスク履歴を表示 |
| `multica agent skills ...` | ネスト: スキルのアタッチ / デタッチ |
| `multica skill list/get/create/update/delete` | スキルの CRUD |
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
## スクワッド
| コマンド | 用途 |
|---|---|
| `multica squad list` | ワークスペースのスクワッドを一覧 |
| `multica squad get <id>` | 単一のスクワッドを表示 |
| `multica squad create --name "..." --leader <agent>` | スクワッドを作成owner / admin |
| `multica squad update <id> ...` | 名前、説明、指示、リーダー、またはアバターを更新 |
| `multica squad delete <id>` | アーカイブ(ソフト削除) — 割り当て済みのイシューをリーダーに移管 |
| `multica squad member list/add/remove <squad-id>` | スクワッドメンバーを管理 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | スクワッドリーダーエージェントがターンごとに評価を記録するために使用 |
完全なモデルについては[スクワッド](/squads)を参照してください。
## オートパイロット
| コマンド | 用途 |
|---|---|
| `multica autopilot list` | ワークスペースのすべてのオートパイロットを一覧 |
| `multica autopilot get <id>` | 単一のオートパイロットを表示 |
| `multica autopilot create ...` | オートパイロットを作成 |
| `multica autopilot update <id> ...` | 更新 |
| `multica autopilot delete <id>` | 削除 |
| `multica autopilot runs <id>` | 実行履歴を表示 |
| `multica autopilot trigger <id>` | 手動で実行をトリガー |
## デーモンとランタイム
| コマンド | 用途 |
|---|---|
| `multica daemon start` | デーモンを起動(デフォルトはバックグラウンド。`--foreground` を追加するとフォアグラウンドで実行) |
| `multica daemon stop` | デーモンを停止 |
| `multica daemon restart` | デーモンを再起動 |
| `multica daemon status` | デーモンがオンラインかどうかと同時実行数を確認 |
| `multica daemon logs` | デーモンのログを表示 |
| `multica runtime list` | 現在のワークスペースのランタイムを一覧 |
| `multica runtime usage` | リソース使用量を表示 |
| `multica runtime activity` | 最近のアクティビティログ |
| `multica runtime update <id> ...` | ランタイムの構成を更新 |
## その他
| コマンド | 用途 |
|---|---|
| `multica repo checkout <url>` | エージェントが使用できるようにリポジトリをローカルにクローン |
| `multica config` | ローカルの CLI 構成を表示または編集 |
| `multica version` | CLI のバージョンを出力 |
| `multica update` | CLI を最新のリリースにアップグレード |
| `multica attachment download <id>` | イシューまたはコメントから添付ファイルをダウンロード |
## 完全なフラグを確認する
すべてのコマンドが `--help` をサポートしています。
```bash
multica issue create --help
multica agent update --help
```
v2 では、各コマンドごとに専用の詳細なリファレンスページを提供する予定です。
## 次のステップ
- [認証とトークン](/auth-tokens) — PAT vs. JWT vs. デーモントークン
- [デーモンとランタイム](/daemon-runtimes) — `daemon` コマンドが内部でどう動作するか
- [エージェントの作成と構成](/agents-create) — `multica agent create` のすべてのオプション

View File

@@ -1,147 +0,0 @@
---
title: CLI 명령어 레퍼런스
description: "모든 최상위 Multica CLI 명령어를 한 페이지로 정리한 개요입니다. 전체 사용법은 `multica <command> --help`를 실행하세요."
---
import { Callout } from "fumadocs-ui/components/callout";
Multica CLI는 Web UI에서 할 수 있는 거의 모든 작업을 그대로 제공합니다([이슈](/issues) 생성, [에이전트](/agents) 할당, [데몬](/daemon-runtimes) 시작 등). 이 페이지는 모든 최상위 명령어를 한 줄 설명과 함께 나열합니다. 전체 플래그와 예제는 `multica <command> --help`를 실행하세요.
## 인증하기
CLI를 처음 사용할 때 이 명령을 실행해 **개인 액세스 토큰(personal access token, PAT)**을 발급받으세요:
```bash
multica login
```
브라우저가 자동으로 열립니다. 웹 앱에서 승인하면 CLI가 PAT(`mul_` 접두사)를 `~/.multica/config.json`에 저장합니다. 이후 모든 명령은 이 PAT로 인증됩니다.
<Callout type="tip">
CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹 앱의 **설정 → API 토큰**에서 PAT를 만든 뒤 `multica login --token <mul_...>`로 직접 전달하면 됩니다.
</Callout>
토큰 유형 간의 차이는 [인증과 토큰](/auth-tokens)을 참고하세요.
## 인증과 설정
| 명령어 | 용도 |
|---|---|
| `multica login` | 로그인하고 PAT 저장 |
| `multica auth status` | 현재 로그인 상태, 사용자, 워크스페이스 표시 |
| `multica auth logout` | 로컬 PAT 삭제 |
| `multica setup cloud` | Multica Cloud용 원샷 설정(로그인 + 데몬 설치) |
| `multica setup self-host` | 자체 호스팅 백엔드용 원샷 설정 |
## 워크스페이스와 멤버
| 명령어 | 용도 |
|---|---|
| `multica workspace list` | 접근할 수 있는 모든 워크스페이스 나열 |
| `multica workspace get <slug>` | 워크스페이스 하나의 상세 정보 표시 |
| `multica workspace member list` | 현재 워크스페이스의 멤버 나열 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 워크스페이스 메타데이터 업데이트(admin/owner). 긴 필드는 `--description-stdin` / `--context-stdin`을 사용할 수 있습니다. |
## 이슈와 프로젝트
<Callout type="info">
`list` 계열 명령(`multica issue list`, `autopilot list`, `project list` 등)은 기본적으로 짧고 **바로 복사해 붙여 넣을 수 있는** ID를 출력합니다. 이슈는 `MUL-123` 같은 이슈 키이고, 나머지 리소스는 짧은 UUID 접두사입니다. 아래 후속 명령의 `<id>` 인자는 짧은 ID와 전체 UUID를 모두 받으므로, 일반적인 흐름은 `multica issue list` → 키 복사 → `multica issue get MUL-123`입니다. 정식 UUID가 필요할 때는 `list` 명령에 `--full-id`를 전달하세요.
</Callout>
| 명령어 | 용도 |
|---|---|
| `multica issue list` | 이슈 나열(복사해 붙여 넣을 수 있는 이슈 키 출력) |
| `multica issue get <id>` | 단일 이슈 표시(이슈 키 또는 UUID를 받음) |
| `multica issue create --title "..."` | 새 이슈 생성 |
| `multica issue update <id> ...` | 이슈 업데이트(상태, 우선순위, 담당자 등) |
| `multica issue assign <id> --agent <slug>` | 에이전트에게 할당(즉시 작업을 트리거) |
| `multica issue status <id> --set <status>` | 상태 변경 단축 명령 |
| `multica issue search <query>` | 키워드 검색 |
| `multica issue runs <id>` | 이슈의 에이전트 실행 표시 |
| `multica issue rerun <id>` | 이슈의 현재 에이전트 담당자에게 새 작업을 다시 큐에 넣기 |
| `multica issue comment <id> ...` | 중첩: 댓글 보기 / 작성 |
| `multica issue subscriber <id> ...` | 중첩: 구독 / 구독 취소 |
| `multica project list/get/create/update/delete/status` | 프로젝트 CRUD |
## 에이전트와 스킬
| 명령어 | 용도 |
|---|---|
| `multica agent list` | 워크스페이스의 에이전트 나열 |
| `multica agent get <slug>` | 에이전트의 구성 표시 |
| `multica agent create ...` | 에이전트 생성 |
| `multica agent update <slug> ...` | 에이전트 업데이트 |
| `multica agent archive <slug>` | 보관 |
| `multica agent restore <slug>` | 보관된 에이전트 복원 |
| `multica agent tasks <slug>` | 에이전트의 작업 기록 표시 |
| `multica agent skills ...` | 중첩: 스킬 연결 / 분리 |
| `multica skill list/get/create/update/delete` | 스킬 CRUD |
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
## 스쿼드
| 명령어 | 용도 |
|---|---|
| `multica squad list` | 워크스페이스의 스쿼드 나열 |
| `multica squad get <id>` | 단일 스쿼드 표시 |
| `multica squad create --name "..." --leader <agent>` | 스쿼드 생성(owner / admin) |
| `multica squad update <id> ...` | 이름, 설명, 지침, 리더, 또는 아바타 업데이트 |
| `multica squad delete <id>` | 보관(소프트 삭제) — 할당된 이슈를 리더에게 이관 |
| `multica squad member list/add/remove/set-role <squad-id>` | 스쿼드 멤버 관리 및 역할 직접 업데이트 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 스쿼드 리더 에이전트가 매 턴마다 평가를 기록할 때 사용 |
전체 모델은 [스쿼드](/squads)를 참고하세요.
## 오토파일럿
| 명령어 | 용도 |
|---|---|
| `multica autopilot list` | 워크스페이스의 모든 오토파일럿 나열 |
| `multica autopilot get <id>` | 단일 오토파일럿 표시 |
| `multica autopilot create ...` | 오토파일럿 생성 |
| `multica autopilot update <id> ...` | 업데이트 |
| `multica autopilot delete <id>` | 삭제 |
| `multica autopilot runs <id>` | 실행 기록 표시 |
| `multica autopilot trigger <id>` | 수동으로 실행 트리거 |
## 데몬과 런타임
| 명령어 | 용도 |
|---|---|
| `multica daemon start` | 데몬 시작(기본은 백그라운드; `--foreground`를 추가하면 포그라운드 실행) |
| `multica daemon stop` | 데몬 중지 |
| `multica daemon restart` | 데몬 재시작 |
| `multica daemon status` | 데몬의 온라인 여부와 동시 실행 수 확인 |
| `multica daemon logs` | 데몬 로그 보기 |
| `multica runtime list` | 현재 워크스페이스의 런타임 나열 |
| `multica runtime usage` | 리소스 사용량 표시 |
| `multica runtime activity` | 최근 활동 로그 |
| `multica runtime update <id> ...` | 런타임의 구성 업데이트 |
## 기타
| 명령어 | 용도 |
|---|---|
| `multica repo checkout <url>` | 에이전트가 사용할 수 있도록 리포지토리를 로컬에 클론 |
| `multica config` | 로컬 CLI 구성 보기 또는 편집 |
| `multica version` | CLI 버전 출력 |
| `multica update` | CLI를 최신 릴리스로 업그레이드 |
| `multica attachment download <id>` | 이슈나 댓글에서 첨부 파일 다운로드 |
## 전체 플래그 확인하기
모든 명령은 `--help`를 지원합니다:
```bash
multica issue create --help
multica agent update --help
```
v2에서는 각 명령마다 전용 상세 레퍼런스 페이지를 제공할 예정입니다.
## 다음 단계
- [인증과 토큰](/auth-tokens) — PAT vs. JWT vs. 데몬 토큰
- [데몬과 런타임](/daemon-runtimes) — `daemon` 명령이 내부적으로 동작하는 방식
- [에이전트 생성과 구성](/agents-create) — `multica agent create`의 모든 옵션

View File

@@ -88,7 +88,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list/add/remove/set-role <squad-id>` | Manage squad members and update roles in place |
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
See [Squads](/squads) for the full model.

View File

@@ -88,7 +88,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
| `multica squad member list/add/remove/set-role <squad-id>` | 管理小队成员并原地更新 role |
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
完整模型见 [小队](/squads)。

View File

@@ -1,119 +0,0 @@
---
title: Cloud クイックスタート
description: サインアップからエージェントへの最初のタスク割り当てまで 5 分で。
---
import { Callout } from "fumadocs-ui/components/callout";
このページは Multica Cloud を最初から最後まで案内します — **サインアップ → [CLI](/cli) のインストール → [デーモン](/daemon-runtimes)の起動 → [エージェント](/agents)の作成 → 最初の[タスク](/tasks)の割り当て**。約 5 分かかります。
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
## 1. アカウントを作成する
[multica.ai](https://multica.ai) でサインアップしてください。メール6 桁の確認コード)または Google でログインできます。
サインアップ後は(アカウント名から生成された)デフォルトのワークスペースに自動的に配置されます。後で名前を変更したり、新しいワークスペースを作成したりできます。
## 2. Multica CLI をインストールする
**macOS / LinuxHomebrew 推奨)**:
```bash
brew install multica-ai/tap/multica
```
**macOS / LinuxHomebrew なし)**:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
**WindowsPowerShell**:
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
インストールを確認します。
```bash
multica version
```
## 3. ログイン + デーモンの起動
コマンド 1 つでログインとデーモンの起動を処理します。
```bash
multica setup
```
`multica setup` は次を実行します。
1. CLI が Multica Cloud に接続するよう構成します
2. ログインのためにブラウザを開きますWeb と同じメール確認コード / Google OAuth
3. 生成された PAT を `~/.multica/config.json` に保存します
4. **デーモンを自動的に起動します** — 3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます
<Callout type="info">
**デスクトップアプリを使用していますか?** デスクトップアプリは起動時に**デーモンを自動的に起動します** — `multica setup` を手動で実行する必要はありません。[デスクトップアプリ](/desktop-app)を参照してください。
</Callout>
デーモンが実行中かどうかを確認します。
```bash
multica daemon status
```
`online` はサーバーに登録されたことを意味します。
## 4. ランタイムがオンラインか確認する
Web UI で **Settings → Runtimes** に移動します。先ほど起動したデーモンが、1 つ以上のアクティブなランタイムとして表示されるはずです — ローカルにインストールされた AI コーディングツールごとに 1 つです。
オフラインと表示されても慌てないでください — [トラブルシューティング → デーモンがサーバーに接続できない](/troubleshooting#daemon-cant-connect-to-the-server)を参照してください。
## 5. エージェントを作成する
Web UI で **Settings → Agents** に移動し、**New Agent** をクリックします。
- **Name** — ボードやコメントでこのエージェントに表示される名前です。好きな名前を選んでください
- **Provider** — ローカルにインストールした AI コーディングツールを選択します(ドロップダウンにはランタイムで検出されたツールのみが表示されます)
- **Model**(任意) — そのツール内部のモデル選択(プロバイダーによって静的な一覧または動的探索)
- **Instructions**(任意) — このエージェントのためのシステムプロンプト
作成されると、エージェントはワークスペースのメンバー一覧に表示され、人間のメンバーと同じように作業を割り当てられます。
## 6. 最初のタスクを割り当てる
Web UI でイシューを作成するか、CLI から作成します。
```bash
multica issue create --title "Add an ASCII architecture diagram to the README"
```
先ほど作成したエージェントにイシューを割り当てます — Web UI でアバターをクリックするか、CLI を使用します。
```bash
multica issue assign MUL-1 --to my-agent-name
```
`--to` はエージェントまたはメンバーの**名前**を受け取ります。部分文字列の一致も機能します — エージェント名が `my-code-reviewer` なら、`reviewer` でそれに解決されます。ワークスペースに名前が重複している場合は、代わりに `--to-id <uuid>``--to` と相互排他を渡してください。UUID は `multica agent list --output json` または `multica workspace member list --output json` で調べられます。
**次にデーモンで起きること**:
1. 3 秒以内にタスクを取得します(ステータスが `queued` から `dispatched` に変わります)
2. 一致する AI コーディングツールを呼び出して作業を開始します(ステータスが `running` になります)
3. AI がローカルで作業します — コードディレクトリを読んだり、コマンドを実行したり、ファイルを編集したりできます
4. 完了すると結果を Multica に報告します(自動リトライが作動するかどうかに応じて、ステータスが `completed` または `failed` になります)
Web UI は**リアルタイムで**WebSocket を通じて)更新されます — 再読み込みは不要です。
## 次のステップ
- [デーモンとランタイム](/daemon-runtimes) — デーモンがどう動作するかとランタイムの意味
- [タスク](/tasks) — タスクのライフサイクルとリトライルール
- [AI コーディングツール比較](/providers) — 12 個のツール間の機能差
- [デスクトップアプリ](/desktop-app) — デーモンを自分で実行したくない場合
- [セルフホストクイックスタート](/self-host-quickstart) — 自前のバックエンドを実行する

View File

@@ -1,119 +0,0 @@
---
title: Cloud 빠른 시작
description: 가입부터 에이전트에게 첫 작업을 할당하기까지 5분 만에.
---
import { Callout } from "fumadocs-ui/components/callout";
이 페이지는 Multica Cloud를 처음부터 끝까지 안내합니다 — **가입 → [CLI](/cli) 설치 → [데몬](/daemon-runtimes) 시작 → [에이전트](/agents) 생성 → 첫 [작업](/tasks) 할당**. 약 5분이 걸립니다.
전제 조건은 하나뿐입니다: 로컬에 [AI 코딩 도구](/providers)([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi) 중 하나)를 이미 최소 하나는 설치해 두어야 합니다. 데몬은 시작할 때 이들을 자동으로 감지하며, 하나도 없으면 시작을 거부합니다.
## 1. 계정 만들기
[multica.ai](https://multica.ai)에서 가입하세요. 이메일(6자리 인증 코드) 또는 Google로 로그인할 수 있습니다.
가입 후에는 (계정 이름으로 생성된) 기본 워크스페이스에 자동으로 배치됩니다. 나중에 이름을 변경하거나 새 워크스페이스를 만들 수 있습니다.
## 2. Multica CLI 설치하기
**macOS / Linux (Homebrew 권장)**:
```bash
brew install multica-ai/tap/multica
```
**macOS / Linux (Homebrew 없이)**:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
**Windows (PowerShell)**:
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
설치를 확인하세요:
```bash
multica version
```
## 3. 로그인 + 데몬 시작하기
명령어 하나로 로그인과 데몬 시작을 처리합니다:
```bash
multica setup
```
`multica setup`은 다음을 수행합니다:
1. CLI가 Multica Cloud에 연결하도록 구성합니다
2. 로그인을 위해 브라우저를 엽니다(웹과 동일한 이메일 인증 코드 / Google OAuth)
3. 생성된 PAT를 `~/.multica/config.json`에 저장합니다
4. **데몬을 자동으로 시작합니다** — 3초마다 작업을 폴링하고 15초마다 하트비트를 전송하기 시작합니다
<Callout type="info">
**데스크톱 앱을 사용 중이신가요?** 데스크톱 앱은 실행 시 **데몬을 자동으로 시작합니다** — `multica setup`을 직접 실행할 필요가 없습니다. [데스크톱 앱](/desktop-app)을 참고하세요.
</Callout>
데몬이 실행 중인지 확인하세요:
```bash
multica daemon status
```
`online`은 서버에 등록되었음을 의미합니다.
## 4. 런타임이 온라인인지 확인하기
웹 UI에서 **설정 → 런타임**으로 이동하세요. 방금 시작한 데몬이 하나 이상의 활성 런타임으로 표시되어야 합니다 — 로컬에 설치된 AI 코딩 도구당 하나씩입니다.
오프라인으로 표시되더라도 당황하지 마세요 — [문제 해결 → 데몬이 서버에 연결할 수 없음](/troubleshooting#daemon-cant-connect-to-the-server)을 참고하세요.
## 5. 에이전트 생성하기
웹 UI에서 **설정 → 에이전트**로 이동하여 **새 에이전트**를 클릭하세요:
- **이름** — 보드와 댓글에서 이 에이전트에 표시되는 이름입니다. 원하는 이름을 고르세요
- **제공자** — 로컬에 설치한 AI 코딩 도구를 선택하세요(드롭다운에는 런타임에서 감지된 도구만 나열됩니다)
- **모델**(선택) — 해당 도구 내부의 모델 선택(제공자에 따라 정적 목록 또는 동적 발견)
- **지침**(선택) — 이 에이전트를 위한 시스템 프롬프트
생성되면 에이전트는 워크스페이스 멤버 목록에 나타나며, 사람 멤버처럼 작업을 할당할 수 있습니다.
## 6. 첫 작업 할당하기
웹 UI에서 이슈를 만들거나, CLI에서 만드세요:
```bash
multica issue create --title "Add an ASCII architecture diagram to the README"
```
방금 만든 에이전트에게 이슈를 할당하세요 — 웹 UI에서 아바타를 클릭하거나, CLI를 사용하세요:
```bash
multica issue assign MUL-1 --to my-agent-name
```
`--to`는 에이전트 또는 멤버의 **이름**을 받습니다. 부분 문자열 일치도 동작합니다 — 에이전트 이름이 `my-code-reviewer`라면 `reviewer`로 해석됩니다. 워크스페이스에 이름이 겹치는 경우, 대신 `--to-id <uuid>`(`--to`와 상호 배타적)를 전달하세요. UUID는 `multica agent list --output json` 또는 `multica workspace member list --output json`으로 조회하세요.
**다음으로 데몬에서 일어나는 일**:
1. 3초 이내에 작업을 가져갑니다(상태가 `queued`에서 `dispatched`로 바뀝니다)
2. 일치하는 AI 코딩 도구를 호출하여 작업을 시작합니다(상태가 `running`이 됩니다)
3. AI가 로컬에서 작업합니다 — 코드 디렉터리를 읽고, 명령을 실행하고, 파일을 편집할 수 있습니다
4. 완료되면 결과를 Multica로 다시 보고합니다(자동 재시도 동작 여부에 따라 상태가 `completed` 또는 `failed`가 됩니다)
웹 UI는 **실시간으로**(WebSocket을 통해) 업데이트됩니다 — 새로 고침이 필요 없습니다.
## 다음 단계
- [데몬과 런타임](/daemon-runtimes) — 데몬이 어떻게 동작하는지와 런타임의 의미
- [작업](/tasks) — 작업 생명주기와 재시도 규칙
- [AI 코딩 도구 비교](/providers) — 12개 도구 간의 기능 차이
- [데스크톱 앱](/desktop-app) — 데몬을 직접 실행하고 싶지 않은 경우
- [자체 호스팅 빠른 시작](/self-host-quickstart) — 자체 백엔드 실행하기

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
## 1. Create an account
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
- [Tasks](/tasks) — task lifecycle and retry rules
- [AI coding tools compared](/providers) — capability differences across the 12 tools
- [AI coding tools compared](/providers) — capability differences across the 11 tools
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
- [Self-host quickstart](/self-host-quickstart) — run your own backend

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket显示进度——不需要刷新
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
- [AI 编程工具对照](/providers) —— 12 款工具的能力差异
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套

View File

@@ -1,81 +0,0 @@
---
title: コメントとメンション
description: イシューの下での共同作業 — コメント、返信、`@` メンション、リアクション、そしてコメントからエージェントをトリガーする方法。
---
import { Callout } from "fumadocs-ui/components/callout";
すべての[イシュー](/issues)にはコメントスレッドがあります。コメントを投稿し、誰かに返信し、[メンバー](/members-roles)や[エージェント](/agents)を `@` でメンションし、リアクションを追加する — これまで使ってきたどのタスク管理ツールでも行ってきたのと同じ操作です。唯一の違いは、**`@` でエージェントをメンションすると、そのエージェントが作業を開始するようトリガーされる**ことです。
## コメントを投稿する
イシュー詳細ページ下部の入力欄に内容を入力し、**送信**を押してください。コメントはすぐにスレッドに表示されます。コメントは Markdown に対応しています — 見出し、リスト、コードブロック、リンクがすべて使えます。
## コメントに返信する
任意のコメントの右上にある**返信**をクリックすると、その下にネストされた入力欄が開きます。返信はそのコメントの子要素として表示され、会話スレッドを形成します。返信にもさらに返信を付けられ、必要なだけ深くネストできます。
イシュー一覧にはトップレベルのコメント数だけが表示され、イシューを開くと会話ツリー全体が見えます。
## リアクション
各コメントの右上には、素早く意思を伝えるためのリアクションボタンがあります(👍、👀、🎉)— 同意を示すために「+1」コメントをわざわざ投稿する必要はありません。
## `@` メンション
コメントに `@` を入力するとピッカーが開きます。メンバーまたはエージェントを選ぶと、`@` と対象のスラッグが挿入されます(`@alice` や `@reviewer-bot`)。メンションされた相手は自分の[インボックス](/inbox)に通知を受け取ります。
**エージェントをメンションすると自動的にトリガーされます** — [コメントでエージェントをメンションする](/mentioning-agents)を参照してください。
1 つのコメントで同じ人を複数回メンションしても、通知は**1 つだけ**発生します。
### `@all` はワークスペース全体に通知する
`@all` は特別な対象です。ワークスペースのすべてのメンバーに通知を送ります。人もエージェントも `@all` を使えます — つまり進捗を報告するエージェントも `@all` できるので、エージェントの指示には控えめに使うよう伝えておきましょう。
<Callout type="warning">
**`@all` は慎重に使ってください。** 規模の大きいワークスペースでは、たった 1 回の `@all` がその人数分のインボックス通知を瞬時に生成します。全員が本当に知る必要があることだけに使い、日常的な更新には使わないでください。
</Callout>
## イシューを参照する
別のイシューをリンクするには、`MUL-123` のようにそのイシューキーを入力してください。Multica はコメント内で実在するイシューキーを解決し、内部的に `mention://issue/<uuid>` リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
通常は `[MUL-123](mention://issue/<uuid>)` を手で書く必要はありません。その形式は、Multica がキーを解決した後に使う標準的な内部表現です。
<Callout type="info">
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
引用符を太字の範囲の外に出すことをおすすめします。
```markdown
"**무엇을 먼저 정해두고 시작할지**"가
```
次の代わりに:
```markdown
**"무엇을 먼저 정해두고 시작할지"**가
```
</Callout>
## コメントの編集と削除
コメントは作成者のみが編集または削除できます。
コメントを削除すると、その下の**すべての返信も一緒に削除されます**(返信への返信も含む)。内容だけを変えたい場合は、削除ではなく編集を使ってください。
<Callout type="warning">
**コメントを編集して `@` を追加しても、エージェントはトリガーされません。** トリガーはコメントが**作成された**その瞬間に発生します — 後から編集して新しい `@` を追加したり、対象を変えたりしても、新しい通知は送られず、エージェントも起きません。見逃したエージェントを呼び出すには、そのエージェントを `@` する**新しいコメントを投稿**してください。
</Callout>
---
ここまで扱ってきた内容はすべて「人の世界」です — ワークスペース、メンバー、イシュー、プロジェクト、コメント。Linear や Jira を使ったことがあれば、これまでの内容はまったく目新しくないはずです。
しかし Multica の決定的な特徴はまだ登場していません。**エージェントをワークスペースの一級メンバーとして扱うこと**です。次はまさにこの話に移ります。
## 次へ
- [エージェント](/agents) — 何であり、人とどう違うのか
- [コメントでエージェントをメンションする](/mentioning-agents) — コメントで `@` を使ってエージェントを起動する

View File

@@ -1,81 +0,0 @@
---
title: 댓글과 멘션
description: 이슈 아래에서 협업하기 — 댓글, 답글, `@` 멘션, 반응, 그리고 댓글에서 에이전트를 트리거하기.
---
import { Callout } from "fumadocs-ui/components/callout";
모든 [이슈](/issues)에는 댓글 스레드가 있습니다. 댓글을 작성하고, 다른 사람에게 답글을 달고, [멤버](/members-roles)나 [에이전트](/agents)를 `@`로 멘션하고, 반응을 추가하세요 — 지금까지 써온 어떤 작업 관리 도구에서나 하던 것과 똑같은 동작입니다. 단 한 가지 다른 점은: **`@`로 에이전트를 멘션하면 에이전트가 작업을 시작하도록 트리거된다**는 것입니다.
## 댓글 작성하기
이슈 상세 페이지 하단의 입력란에 내용을 입력하고 **보내기**를 누르세요. 댓글이 스레드에 즉시 나타납니다. 댓글은 Markdown을 지원합니다 — 제목, 목록, 코드 블록, 링크를 모두 사용할 수 있습니다.
## 댓글에 답글 달기
어떤 댓글이든 오른쪽 상단의 **답글**을 클릭하면 그 아래에 중첩된 입력란이 열립니다. 작성한 답글은 해당 댓글의 하위 항목으로 표시되어 대화 스레드를 이룹니다. 답글에도 다시 답글을 달 수 있으며, 필요한 만큼 깊이 중첩됩니다.
이슈 목록에는 최상위 댓글 수만 표시되며, 이슈를 열어야 전체 대화 트리를 볼 수 있습니다.
## 반응
각 댓글의 오른쪽 상단에는 빠른 신호를 보내기 위한 반응 버튼이 있습니다(👍, 👀, 🎉) — 동의를 표시하려고 "+1" 댓글을 따로 달 필요가 없습니다.
## `@` 멘션
댓글에 `@`를 입력하면 선택 창이 열립니다. 멤버나 에이전트를 고르면 `@`와 대상의 slug가 함께 삽입됩니다(`@alice` 또는 `@reviewer-bot`). 멘션된 대상은 자신의 [인박스](/inbox)에서 알림을 받습니다.
**에이전트를 멘션하면 자동으로 트리거됩니다** — [댓글에서 에이전트 멘션하기](/mentioning-agents)를 참고하세요.
하나의 댓글에서 같은 사람을 여러 번 멘션해도 알림은 **하나만** 발생합니다.
### `@all`은 워크스페이스 전체에 알립니다
`@all`은 특별한 대상입니다. 워크스페이스의 모든 멤버에게 알림을 보냅니다. 사람과 에이전트 모두 `@all`을 사용할 수 있습니다 — 즉 진행 상황을 보고하는 에이전트도 `@all`을 할 수 있으므로, 에이전트의 지침에 이를 아껴서 사용하라고 일러두세요.
<Callout type="warning">
**`@all`은 신중하게 사용하세요.** 규모가 큰 워크스페이스에서는 단 한 번의 `@all`이 그만큼의 인박스 알림을 순식간에 생성합니다. 모두가 정말로 알아야 하는 일에만 사용하고 — 일상적인 업데이트에는 쓰지 마세요.
</Callout>
## 이슈 참조하기
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
<Callout type="info">
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
따옴표를 굵게 표시 범위 밖으로 옮기는 것을 권장합니다:
```markdown
"**무엇을 먼저 정해두고 시작할지**"가
```
다음 대신:
```markdown
**"무엇을 먼저 정해두고 시작할지"**가
```
</Callout>
## 댓글 편집과 삭제
댓글은 작성자만 편집하거나 삭제할 수 있습니다.
댓글을 삭제하면 그 아래의 **모든 답글도 함께 삭제됩니다**(답글의 답글 포함). 내용만 바꾸려면 삭제 대신 편집을 사용하세요.
<Callout type="warning">
**댓글을 편집하면서 `@`를 추가해도 에이전트는 트리거되지 않습니다.** 트리거는 댓글이 **생성되는** 그 순간에 발생합니다 — 나중에 편집해서 새로운 `@`를 추가하거나 대상을 바꿔도 새 알림이 발송되지 않고 에이전트가 깨어나지도 않습니다. 놓친 에이전트를 불러오려면 그 에이전트를 `@`하는 **새 댓글을 작성**하세요.
</Callout>
---
여기까지 다룬 내용은 모두 "사람의 세계"입니다 — 워크스페이스, 멤버, 이슈, 프로젝트, 댓글. Linear나 Jira를 써본 적이 있다면 지금까지의 내용은 전혀 낯설지 않을 것입니다.
하지만 Multica의 결정적인 특징은 아직 등장하지 않았습니다: **에이전트를 워크스페이스의 일급 멤버로 대우하는 것**입니다. 다음 장에서 바로 이 이야기를 다룹니다.
## 다음
- [에이전트](/agents) — 무엇이며, 사람과 어떻게 다른지
- [댓글에서 에이전트 멘션하기](/mentioning-agents) — 댓글에서 `@`로 에이전트를 시작시키기

View File

@@ -37,28 +37,6 @@ Mentioning the same person multiple times in one comment still produces **only o
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not day-to-day updates.
</Callout>
## Referencing issues
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
<Callout type="info">
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
Prefer moving the quote outside the bold span:
```markdown
"**무엇을 먼저 정해두고 시작할지**"가
```
instead of:
```markdown
**"무엇을 먼저 정해두고 시작할지"**가
```
</Callout>
## Editing and deleting a comment
Only the author of a comment can edit or delete it.

View File

@@ -37,28 +37,6 @@ import { Callout } from "fumadocs-ui/components/callout";
**谨慎使用 `@all`**。工作区人数较多时,一条 `@all` 的评论会瞬间生成同等数量的收件箱通知。只在确实需要全员知晓的重大事项上使用——不是日常琐事。
</Callout>
## 引用 issue
要链接另一个 issue直接输入它的 issue key例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式。
<Callout type="info">
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
推荐把引号放到加粗范围外:
```markdown
"**무엇을 먼저 정해두고 시작할지**"가
```
而不是:
```markdown
**"무엇을 먼저 정해두고 시작할지"**가
```
</Callout>
## 编辑和删除评论
只有评论的作者能编辑或删除自己的评论。

View File

@@ -1,111 +0,0 @@
---
title: デーモンとランタイム
description: エージェントは Multica のサーバーでは実行されません — あなた自身のマシンで実行されます。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica では、[エージェント](/agents)は私たちのサーバーでは実行され**ません** — ローカルにインストールされた [AI コーディングツール](/providers)を呼び出す**デーモン**という小さなプログラムが駆動し、あなた自身のマシンで実行されます。Multica サーバーは調整役に徹します。[イシュー](/issues)を保存し、[タスク](/tasks)をキューに入れ、適切な**ランタイム**へ分配します(ランタイム = デーモン × AI コーディングツール 1 つ)。
この構造が Multica と Linear / Jira の最大の違いです。**あなたの API キー、ツールチェーン、コードディレクトリはすべてあなたのマシンに残り**、Multica サーバーはそのどれも見ることはありません。つまり「自分のエージェントが動かない」はほとんど常にローカルの問題です。デーモンが実行されていない、AI ツールがインストールされていない、キーが期限切れになっている、といったことです。まずローカルを確認してください。案内は[トラブルシューティング](/troubleshooting)を参照してください。
## デーモンを起動する
デーモンは Multica CLI の一部です。[Multica CLI](/cli) をインストールしたら、あなた自身のマシンで実行してください。
```bash
multica daemon start
```
起動時にデーモンは 4 つのことを行います。
1. ログイン時に保存された認証情報を読み込みます
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます
よく使うコマンド:
| コマンド | 用途 |
|---|---|
| `multica daemon start` | 起動(デフォルトはバックグラウンド。フォアグラウンドで実行するには `--foreground` を追加) |
| `multica daemon stop` | 停止 |
| `multica daemon restart` | 再起動 |
| `multica daemon status` | ステータス表示 |
| `multica daemon logs` | ログ表示(追従するには `-f` を追加) |
完全な CLI リファレンスは [CLI コマンド](/cli)を確認してください。
**デスクトップアプリにはデーモンが同梱されています。** [デスクトップアプリ](/desktop-app)を使う場合、`multica daemon start` を手動で実行する必要はありません。起動時にデーモンを自動的に立ち上げます。あなたのワークフローにどの方式が合うかは、[デスクトップアプリ](/desktop-app)ページを参照してください。
## 1 つのマシンに複数のランタイムができる理由
ランタイムはサーバーでもコンテナでもありません。「**デーモン × AI コーディングツール 1 つ**」の組み合わせです。たとえば、Claude Code と Codex の両方がインストールされた MacBook でデーモンを起動し、あなたが 2 つのワークスペースのメンバーだとします。すると Multica は 4 つのランタイムを登録します。
<Mermaid chart={`
graph TD
D["あなたのデーモン<br/>MacBook"]
D --> R1["ランタイム<br/>ワークスペース A × Claude Code"]
D --> R2["ランタイム<br/>ワークスペース A × Codex"]
D --> R3["ランタイム<br/>ワークスペース B × Claude Code"]
D --> R4["ランタイム<br/>ワークスペース B × Codex"]
`} />
要点:
- **1 つのデーモンは複数のランタイムにマッピングされ得ます** — インストールされたツールと、あなたが所属するワークスペースの組み合わせごとに 1 つできます
- **同じデーモン、ワークスペース、ツールは、ちょうど 1 つのランタイムを作ります** — デーモンを再起動しても重複レコードは生まれません
- Multica UI の**ランタイム**ページがこれらの行を一覧表示します
<Callout type="info">
**クラウドランタイムが近日提供されます。** 現在は順番待ちリストの段階です。提供が始まれば、ローカルのデーモンを実行せずに Multica Cloud 上で直接エージェントタスクを実行できるようになります。[ダウンロードページ](https://multica.ai/download)でメールアドレスを登録すると通知を受け取れます。
</Callout>
## ランタイムがオフラインと表示される時点
Multica はハートビートでランタイムがオンラインかどうかを判断します。3 つの重要な数値があります。
| イベント | しきい値 |
|---|---|
| デーモンのハートビート頻度 | **15 秒**ごと |
| 欠落として表示 | **45 秒**間ハートビートなし3 回欠落) |
| 自動削除 | 関連するエージェントがない状態で **7 日**以上欠落 |
欠落は永続的ではありません。デーモンが再びハートビートを送った瞬間にオンラインに戻り、ランタイムレコードも保持されます。デーモンを再起動してもランタイムは失われません。
<Callout type="warning">
**欠落したランタイムで実行中だったタスクは失敗として表示されます**(失敗理由 `runtime_offline`。リトライ可能なソースイシュー、チャットについては、Multica が自動的に再度キューに入れます。オートパイロットがトリガーしたタスクは自動的にはリトライされません。[タスク → どの失敗が自動リトライされるか](/tasks#which-failures-retry-automatically-which-dont)を参照してください。
</Callout>
## いくつのタスクを並列に実行できるか
Multica は 2 つの層で同時実行数の制限を適用します。
- **デーモン層**: デフォルトで**同時タスク 20 個**(環境変数 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` で調整可能)
- **エージェント層**: デフォルトで**エージェントあたり同時タスク 6 個**(エージェントごとに設定)
2 つのうち厳しい方が適用されます。デーモンがすでにタスク 20 個を実行中なら、あるエージェントに余裕が残っていても新しいタスクは待機します。
タスクが `dispatched` に進めず `queued` で止まっている場合、通常はこの 2 つの制限のいずれかが飽和しています。
## デーモンのクラッシュ後、進行中だったタスクはどうなるか
デーモンがクラッシュしたり強制終了されたりすると、デーモンが取得していたタスクは `dispatched` または `running` 状態に残ります。次回の起動時、デーモンはサーバーに「これらのタスクはもう私のものではないので、失敗として表示してください」と伝えます。サーバーはそれを理由 `runtime_recovery` とともに `failed` に切り替えます。リトライ可能なソースについては、タスクが自動的に再度キューに入ります。
この手順がネットワークの問題で失敗しても、バックアップとして**30 秒ごと**にサーバー側のスキャンが回ります。45 秒以上ハートビートのないランタイムは欠落として表示され、その上のタスクも一緒に回収されます。
## 動かないエージェントのトラブルシューティング
「自分のエージェントが動かない」という問題に遭遇したら、まずこの 3 ステップのチェックリストを進めてください。
1. `multica daemon status` を実行し、デーモンが実行中でオンラインかを確認します
2. `multica daemon logs -f` を実行し、エラーがないかを確認します
3. Multica UI の**ランタイム**ページを開き、ランタイムが「オンライン」と表示されているかを確認します
より多くのシナリオは[トラブルシューティング](/troubleshooting)を参照してください。
## 次へ
- [タスク](/tasks) — デーモンがタスクを取得した後の全ライフサイクル
- [プロバイダー対照表](/providers) — 12 種の AI コーディングツールの機能の違い

View File

@@ -1,111 +0,0 @@
---
title: 데몬과 런타임
description: 에이전트는 Multica 서버에서 실행되지 않습니다. 여러분의 기기에서 실행됩니다.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica에서 [에이전트](/agents)는 우리 서버에서 실행되지 **않습니다**. 로컬에 설치된 [AI 코딩 도구](/providers)를 호출하는 **데몬**이라는 작은 프로그램이 구동하여, 여러분 자신의 기기에서 실행됩니다. Multica 서버는 조율만 담당합니다. [이슈](/issues)를 저장하고, [작업](/tasks)을 대기열에 넣고, 알맞은 **런타임**으로 분배합니다(런타임 = 데몬 × AI 코딩 도구 하나).
이 구조가 Multica와 Linear / Jira의 가장 큰 차이점입니다. **여러분의 API 키, 툴체인, 코드 디렉터리는 모두 여러분의 기기에 남아 있으며**, Multica 서버는 그중 어느 것도 보지 못합니다. 따라서 "내 에이전트가 동작하지 않는다"는 거의 항상 로컬 문제입니다. 데몬이 실행 중이 아니거나, AI 도구가 설치되어 있지 않거나, 키가 만료되었을 수 있습니다. 먼저 로컬을 확인하세요. 안내는 [문제 해결](/troubleshooting)을 참고하세요.
## 데몬 시작하기
데몬은 Multica CLI의 일부입니다. [Multica CLI](/cli)를 설치한 뒤, 여러분의 기기에서 실행하세요.
```bash
multica daemon start
```
시작 시 데몬은 네 가지 일을 합니다.
1. 로그인할 때 저장된 인증 정보를 읽습니다
2. `PATH`에 설치된 AI 코딩 도구를 감지합니다(내장 12종: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. 감지된 각 도구에 대한 런타임과 함께 자신을 서버에 등록합니다
4. **3초마다** 가져올 작업이 있는지 폴링하고, **15초마다 하트비트를 전송**합니다
자주 쓰는 명령:
| 명령 | 용도 |
|---|---|
| `multica daemon start` | 시작(기본은 백그라운드. 포그라운드로 실행하려면 `--foreground` 추가) |
| `multica daemon stop` | 중지 |
| `multica daemon restart` | 재시작 |
| `multica daemon status` | 상태 표시 |
| `multica daemon logs` | 로그 표시(따라가려면 `-f` 추가) |
전체 CLI 참고는 [CLI 명령](/cli)을 확인하세요.
**데스크톱 앱에는 데몬이 포함되어 있습니다.** [데스크톱 앱](/desktop-app)을 사용한다면 `multica daemon start`를 수동으로 실행할 필요가 없습니다. 시작할 때 데몬을 자동으로 띄웁니다. 여러분의 워크플로에 어떤 방식이 맞는지는 [데스크톱 앱](/desktop-app) 페이지를 참고하세요.
## 한 기기에 여러 런타임이 생기는 이유
런타임은 서버도 아니고 컨테이너도 아닙니다. "**데몬 × AI 코딩 도구 하나**"의 조합입니다. 예를 들어, Claude Code와 Codex가 모두 설치된 MacBook에서 데몬을 시작하고, 여러분이 두 개의 워크스페이스 멤버라고 합시다. 그러면 Multica는 4개의 런타임을 등록합니다.
<Mermaid chart={`
graph TD
D["여러분의 데몬<br/>MacBook"]
D --> R1["런타임<br/>워크스페이스 A × Claude Code"]
D --> R2["런타임<br/>워크스페이스 A × Codex"]
D --> R3["런타임<br/>워크스페이스 B × Claude Code"]
D --> R4["런타임<br/>워크스페이스 B × Codex"]
`} />
핵심 포인트:
- **하나의 데몬은 여러 런타임에 매핑될 수 있습니다.** 설치된 도구와 가입한 워크스페이스의 조합마다 하나씩 생깁니다
- **같은 데몬, 워크스페이스, 도구는 정확히 하나의 런타임을 만듭니다.** 데몬을 재시작해도 중복 레코드가 생기지 않습니다
- Multica UI의 **런타임** 페이지가 이 행들을 나열합니다
<Callout type="info">
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기자 명단 단계입니다. 제공이 시작되면 로컬 데몬을 실행하지 않고도 Multica Cloud에서 직접 에이전트 작업을 실행할 수 있습니다. [다운로드 페이지](https://multica.ai/download)에서 이메일로 등록하면 알림을 받을 수 있습니다.
</Callout>
## 런타임이 오프라인으로 표시되는 시점
Multica는 하트비트로 런타임이 온라인인지 판단합니다. 세 가지 핵심 수치:
| 이벤트 | 임곗값 |
|---|---|
| 데몬 하트비트 빈도 | **15초**마다 |
| 누락으로 표시 | **45초** 동안 하트비트 없음(3회 누락) |
| 자동 삭제 | 연결된 에이전트 없이 **7일** 넘게 누락 상태 |
누락은 영구적이지 않습니다. 데몬이 다시 하트비트를 보내는 즉시 온라인으로 돌아오며, 런타임 레코드도 보존됩니다. 데몬을 재시작해도 런타임은 사라지지 않습니다.
<Callout type="warning">
**누락된 런타임에서 실행 중이던 작업은 실패로 표시됩니다**(실패 사유 `runtime_offline`). 재시도 가능한 출처(이슈, 채팅)에 대해서는 Multica가 자동으로 다시 대기열에 넣습니다. 오토파일럿이 트리거한 작업은 자동으로 재시도되지 않습니다. [작업 → 어떤 실패가 자동 재시도되는지](/tasks#which-failures-retry-automatically-which-dont)를 참고하세요.
</Callout>
## 동시에 실행할 수 있는 작업 수
Multica는 두 계층에서 동시성 제한을 적용합니다.
- **데몬 계층**: 기본 **동시 작업 20개**(환경 변수 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`로 조정 가능)
- **에이전트 계층**: 기본 **에이전트당 동시 작업 6개**(에이전트별로 설정)
둘 중 더 엄격한 쪽이 적용됩니다. 데몬이 이미 작업 20개를 실행 중이라면, 어떤 에이전트에 여유가 남아 있어도 새 작업은 대기합니다.
작업이 `dispatched`로 넘어가지 못하고 `queued`에 멈춰 있다면, 보통 이 두 제한 중 하나가 포화 상태인 것입니다.
## 데몬 충돌 후 진행 중이던 작업은 어떻게 되나
데몬이 충돌하거나 강제 종료되면, 데몬이 가져갔던 작업은 `dispatched` 또는 `running` 상태에 남습니다. 다음 시작 시 데몬은 서버에 "이 작업들은 더 이상 제 것이 아니니, 실패로 표시해 주세요"라고 알립니다. 서버는 이를 사유 `runtime_recovery`와 함께 `failed`로 전환합니다. 재시도 가능한 출처에 대해서는 작업이 자동으로 다시 대기열에 들어갑니다.
이 단계가 네트워크 문제로 실패하더라도, 백업으로 **30초마다** 서버 측 스캔이 돕니다. 45초 넘게 하트비트가 없는 런타임은 누락으로 표시되며, 그 위의 작업도 함께 회수됩니다.
## 동작하지 않는 에이전트 문제 해결
"내 에이전트가 동작하지 않는다"는 문제를 만나면, 먼저 이 세 단계 체크리스트를 진행하세요.
1. `multica daemon status`를 실행해 데몬이 실행 중이고 온라인인지 확인하세요
2. `multica daemon logs -f`를 실행해 오류가 있는지 확인하세요
3. Multica UI의 **런타임** 페이지를 열어 런타임이 "온라인"으로 표시되는지 확인하세요
더 많은 시나리오는 [문제 해결](/troubleshooting)을 참고하세요.
## 다음
- [작업](/tasks) — 데몬이 작업을 가져온 뒤의 전체 생애 주기
- [제공자 대조표](/providers) — 12종 AI 코딩 도구의 기능 차이

View File

@@ -21,7 +21,7 @@ multica daemon start
On startup it does four things:
1. Reads the credentials saved when you logged in
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. Registers itself with the server, along with a runtime for each detected tool
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
## Next
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools

View File

@@ -21,7 +21,7 @@ multica daemon start
启动后它会做四件事:
1. 读取你登录时保存的凭证
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照

View File

@@ -1,99 +0,0 @@
---
title: デスクトップアプリ
description: Multica Desktop とは何か、Web アプリとどう違うのか、そしてどんなときに使う価値があるのかを解説します。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop は macOS、Windows、Linux 向けのネイティブデスクトップアプリです。設定された環境に対して、Web アプリと同じバックエンドに接続し、同じデータを表示します。Desktop はデフォルトで Multica Cloud を使用しますが、セルフホスト環境はローカルのランタイム設定ファイルで構成できます。Desktop はブラウザにはできないいくつかの機能も追加で提供します。**[ワークスペース](/workspaces)ごとの独立したタブグループ**、**[デーモン](/daemon-runtimes)の自動起動**、**ワンクリックアップグレード**です。
## Desktop か Web か — どちらを選ぶか
| | Web | Desktop |
|---|---|---|
| アクセス方法 | ブラウザで URL を開く | ネイティブアプリをインストール |
| 複数タブ | ブラウザ自体のタブ(ワークスペースの区別なし) | **ワークスペースごとに独立したタブグループ 1 つ** |
| デーモン | `multica daemon start` を自分で実行 | 起動時に**自動的に開始** |
| アップグレード | 更新すると最新版になる | アプリがバックグラウンドで確認し、次回起動時にインストール |
| ログイン後のデータ | 同一 | 同一 |
**Web を選ぶ**: 一度きりの利用、他人のマシンでの作業、何もインストールしたくないとき。
**Desktop を選ぶ**: 毎日の利用、複数のワークスペースを同時に扱うとき、デーモンを手動で管理したくないとき。
## 複数タブ: ワークスペースを切り替えるとどうなるか
Desktop は**参加しているすべてのワークスペース**ごとに独立したタブグループを保持します。ワークスペースを切り替えると、現在のワークスペースのタブが 1 つの単位として非表示になり、以前のワークスペースのタブは離れたときのまま復元されます — VSCode のマルチワークスペースの挙動や、Slack でワークスペースを切り替えるのに似ています。
例: ワークスペース A でイシューのタブを 3 つ開いた状態でワークスペース B に切り替えます。A のタブ 3 つは消え、B には B で最後に開いていたものが表示されます。再び A に切り替えると、その 3 つのタブが以前の状態そのままに戻ってきます。**タブはワークスペース間で決して漏れ出しません。**
ログアウトすると**すべてのワークスペースのタブ状態が消去される**ため、複数のユーザーでマシンを共有していてもデータが漏れることはありません。
## Desktop の自動更新の仕組み
起動時に Desktop は GitHub Releases でより新しいバージョンがないかを確認します。新しいバージョンが見つかると、
1. バックグラウンドで新しいバージョンを静かにダウンロードします。
2. 「準備完了 — 次回起動時にインストールされます」と通知します。
3. 終了時(または次回の再起動時)に、アプリが閉じる前に更新をインストールします。
4. 次回起動時に新しいバージョンが実行されます。
このプロセス全体は**作業中の内容を妨げません**。
<Callout type="warning">
**Windows では ARM64 と x64 は別々の更新チャンネルです** — 間違ったアーキテクチャをインストールすると更新が検出されません。ダウンロードする際は、マシンに合った `.exe` を選んでくださいARM ビルドには `arm64` のサフィックスが付いています)。
</Callout>
macOS ビルドは署名・公証されているため、初回起動時に「未確認の開発者」の警告は表示されません。Linux ビルドは `.AppImage` です — 自動更新は electron-updater に依存しており、一部のディストリビューションでは不安定になることがあります。**自動更新が動作しない場合は、新しいバージョンを手動でダウンロードして古いファイルを置き換えてください。**
## 単体の CLI とデーモンはまだ必要ですか?
**いいえ。** Desktop には同じ `multica` CLI バイナリが内蔵されており、起動時に独自のデーモンプロファイルを起動します(ターミナルから手動で実行しているデーモンとは隔離されます)。
すでに CLI をインストールして `multica daemon start` を手動で実行していても、Desktop はそのデーモンを乗っ取りません — 別のプロファイルで独自のデーモンを開始します。両者は**異なるランタイム**として登録され、UI では 2 つの独立したランタイムが表示されます。
ターミナルで CLI コマンドを実行したい場合、Desktop は特別な経路を提供しません — 別途インストールした CLI を使うか、アプリのリソースディレクトリ内 `resources/bin/multica` にあるバンドル済みのコピーを実行してください。
## ダウンロードとインストール
[Multica ダウンロードページ](https://multica.ai/download)から、使用するプラットフォームのインストーラーを入手してください。
| プラットフォーム | ファイル |
|---|---|
| macOS (Intel または Apple Silicon) | `.dmg` |
| Windows x64 | `.exe`(標準) |
| Windows ARM64 | `.exe``arm64` サフィックス付き) |
| Linux | `.AppImage` |
初回起動時にはログインが必要です — Web アプリと同じメール + 認証コードのフローです。ログインすると、Desktop はワークスペース一覧を自動的に同期します。
<Callout type="info">
**Desktop はデフォルトで Multica Cloud を使用しますが、ローカルの設定ファイルでセルフホスト環境を指すように設定できます。** アプリ内には依然として「セルフホストに接続」を選ぶピッカーはありません。Desktop はレンダラーが起動する前に `~/.multica/desktop.json` を読み込みます。ファイルがない場合は Cloud のデフォルト値を使用します。
最小構成のセルフホスト設定:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
`apiUrl` は必須で、`http` または `https` を使用する必要があります。Desktop は同一オリジン上で `wsUrl` を `/ws` として導出し(`https` なら `wss`、`http` なら `ws`、API オリジンから `appUrl` を導出します。デプロイ環境が異なるオリジンを使用する場合は明示的に設定してください。
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
`desktop.json` は存在するが無効な場合、Desktop は安全側に倒して動作を停止し、Cloud に静かにフォールバックする代わりにブロック型の設定エラーを表示します。開発ビルドの場合、`electron-vite dev` 中は依然として `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` が優先されます。ランタイムでの Desktop セルフホスト構成は [issue #1371](https://github.com/multica-ai/multica/issues/1371) で実装されました。
</Callout>
## 次のステップ
- [Cloud Quickstart](/cloud-quickstart) — Desktop 向けの Cloud オンボーディングフロー
- [Self-Host Quickstart](/self-host-quickstart) — 自前のバックエンドを実行し、CLI または Desktop のランタイム設定で接続する
- [デーモンとランタイム](/daemon-runtimes) — デーモンの仕組みDesktop が代わりに起動してくれますが、動作は同じです)

View File

@@ -1,99 +0,0 @@
---
title: 데스크톱 앱
description: Multica Desktop이 무엇인지, 웹 앱과 어떻게 다른지, 언제 쓸 만한지 알아봅니다.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop은 macOS, Windows, Linux용 네이티브 데스크톱 앱입니다. 구성된 환경에 대해 웹 앱과 동일한 백엔드에 연결되며 동일한 데이터를 보여줍니다. 기본적으로 Desktop은 Multica Cloud를 사용하며, 자체 호스팅 인스턴스는 로컬 런타임 설정 파일로 구성할 수 있습니다. Desktop은 브라우저가 할 수 없는 몇 가지 기능도 추가로 제공합니다: **[워크스페이스](/workspaces)별 독립적인 탭 그룹**, **[데몬](/daemon-runtimes) 자동 시작**, **원클릭 업그레이드**입니다.
## Desktop 또는 웹 — 무엇을 선택할까
| | 웹 | Desktop |
|---|---|---|
| 접근 방식 | 브라우저에서 URL 열기 | 네이티브 앱 설치 |
| 다중 탭 | 브라우저 자체 탭 (워크스페이스 구분 없음) | **워크스페이스별 독립 탭 그룹 한 개** |
| 데몬 | `multica daemon start`를 직접 실행 | 실행 시 **자동으로 시작** |
| 업그레이드 | 새로고침하면 최신 버전 | 앱이 백그라운드에서 확인하고 다음 실행 시 설치 |
| 로그인 후 데이터 | 동일 | 동일 |
**웹을 선택**: 일회성으로 사용하거나, 다른 사람의 기기에서 작업하거나, 아무것도 설치하고 싶지 않을 때.
**Desktop을 선택**: 매일 사용하거나, 여러 워크스페이스를 동시에 다루거나, 데몬을 수동으로 관리하고 싶지 않을 때.
## 다중 탭: 워크스페이스를 전환하면 어떻게 되나
Desktop은 **참여한 모든 워크스페이스**마다 독립적인 탭 그룹을 유지합니다. 워크스페이스를 전환하면 현재 워크스페이스의 탭이 하나의 단위로 숨겨지고, 이전 워크스페이스의 탭은 떠났던 그대로 복원됩니다 — VSCode의 멀티 워크스페이스 동작이나 Slack에서 워크스페이스를 전환하는 것과 비슷합니다.
예시: 워크스페이스 A에서 이슈 탭 3개를 열어 둔 상태로 워크스페이스 B로 전환합니다. A의 탭 3개는 사라지고, B에는 B에서 마지막으로 열어 두었던 것이 표시됩니다. 다시 A로 전환하면 그 3개의 탭이 정확히 이전 상태 그대로 돌아옵니다. **탭은 워크스페이스 간에 절대 새어 나가지 않습니다.**
로그아웃하면 **모든 워크스페이스의 탭 상태가 지워지므로**, 여러 사용자가 기기를 공유하더라도 데이터가 새어 나가지 않습니다.
## Desktop의 자동 업데이트 방식
실행 시 Desktop은 GitHub Releases에서 더 최신 버전이 있는지 확인합니다. 새 버전이 발견되면:
1. 백그라운드에서 새 버전을 조용히 다운로드합니다.
2. "준비 완료 — 다음 실행 시 설치됩니다"라고 알려 줍니다.
3. 종료(또는 다음 재시작) 시, 앱이 닫히기 전에 업데이트를 설치합니다.
4. 다음 실행 시 새 버전이 실행됩니다.
이 전체 과정은 **작업 중인 내용을 방해하지 않습니다**.
<Callout type="warning">
**Windows에서는 ARM64와 x64가 별도의 업데이트 채널입니다** — 잘못된 아키텍처를 설치하면 업데이트가 감지되지 않습니다. 다운로드할 때 기기에 맞는 `.exe`를 선택하세요 (ARM 빌드에는 `arm64` 접미사가 붙어 있습니다).
</Callout>
macOS 빌드는 서명 및 공증되어 있으므로, 첫 실행 시 "확인되지 않은 개발자" 경고가 표시되지 않습니다. Linux 빌드는 `.AppImage`입니다 — 자동 업데이트는 electron-updater에 의존하는데, 일부 배포판에서는 불안정할 수 있습니다. **자동 업데이트가 작동하지 않으면, 새 버전을 수동으로 다운로드하여 기존 파일을 교체하세요.**
## 별도의 CLI와 데몬이 여전히 필요한가요?
**아닙니다.** Desktop에는 동일한 `multica` CLI 바이너리가 내장되어 있으며, 시작 시 자체 데몬 프로필을 실행합니다 (터미널에서 수동으로 실행 중인 데몬과는 격리됩니다).
이미 CLI를 설치하고 `multica daemon start`를 직접 실행했더라도, Desktop은 그 데몬을 가로채지 않습니다 — 별도의 프로필로 자체 데몬을 시작합니다. 둘은 **서로 다른 런타임**으로 등록되며, UI에서 두 개의 독립된 런타임을 볼 수 있습니다.
터미널에서 CLI 명령을 실행하고 싶다면, Desktop이 특별한 경로를 제공하지는 않습니다 — 별도로 설치한 CLI를 사용하거나, 앱의 리소스 디렉터리 안 `resources/bin/multica`에 있는 번들 사본을 실행하세요.
## 다운로드 및 설치
[Multica 다운로드 페이지](https://multica.ai/download)에서 사용하는 플랫폼의 설치 프로그램을 받으세요:
| 플랫폼 | 파일 |
|---|---|
| macOS (Intel 또는 Apple Silicon) | `.dmg` |
| Windows x64 | `.exe` (표준) |
| Windows ARM64 | `.exe` (`arm64` 접미사 포함) |
| Linux | `.AppImage` |
첫 실행 시 로그인이 필요합니다 — 웹 앱과 동일한 이메일 + 인증 코드 흐름입니다. 로그인하면 Desktop이 워크스페이스 목록을 자동으로 동기화합니다.
<Callout type="info">
**Desktop은 기본적으로 Multica Cloud를 사용하지만, 로컬 설정 파일로 자체 호스팅 인스턴스를 가리키도록 할 수 있습니다.** 앱 안에는 여전히 "자체 호스팅에 연결" 선택기가 없습니다. Desktop은 렌더러가 시작되기 전에 `~/.multica/desktop.json`을 읽습니다. 파일이 없으면 Cloud 기본값을 사용합니다.
최소 자체 호스팅 설정:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
`apiUrl`은 필수이며 `http` 또는 `https`를 사용해야 합니다. Desktop은 동일 오리진에서 `wsUrl`을 `/ws`로 유도하고(`https`이면 `wss`, `http`이면 `ws`), API 오리진에서 `appUrl`을 유도합니다. 배포 환경이 서로 다른 오리진을 사용한다면 명시적으로 설정하세요:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
`desktop.json`이 존재하지만 유효하지 않으면, Desktop은 안전하게 동작을 차단하여 Cloud로 조용히 되돌아가는 대신 차단형 설정 오류를 표시합니다. 개발 빌드의 경우, `electron-vite dev` 중에는 여전히 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`이 우선합니다. 런타임 Desktop 자체 호스팅 구성은 [issue #1371](https://github.com/multica-ai/multica/issues/1371)에서 구현되었습니다.
</Callout>
## 다음 단계
- [Cloud Quickstart](/cloud-quickstart) — Desktop을 위한 Cloud 온보딩 흐름
- [Self-Host Quickstart](/self-host-quickstart) — 자체 백엔드를 실행하고 CLI 또는 Desktop 런타임 설정으로 연결하기
- [데몬 및 런타임](/daemon-runtimes) — 데몬의 작동 방식 (Desktop이 대신 시작해 주지만 동작은 동일합니다)

View File

@@ -1,302 +0,0 @@
---
title: 規約
description: コードのネーミング、i18n 翻訳用語集、中国語ボイスガイドの単一の信頼できる情報源。
---
このページは、コードのネーミング、i18n 翻訳用語集、中国語ボイスガイドの単一の信頼できる情報源です。かつて `packages/views/locales/glossary.md` やあちこちに散らばったコメントにあった内容は、現在すべてここに集約されています。
Multica のコードを書く、翻訳を変更する、あるいは中国語の製品コピーを書く場合は、このページを参照してください。
---
## 1. コードのネーミング
### ルート
ワークスペース進入前のルート(ユーザーがワークスペースに入る前から存在するルート)は、必ず単一の単語または `/{noun}/{verb}` パターンを使用しなければなりません。
- ✅ `/login`, `/inbox`, `/workspaces/new`
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
ルート直下のハイフンで連結した単語のまとまりは、ユーザーが自分で選んだワークスペースの slug と衝突し、際限のない予約 slug の監査を強いることになります。名詞(`workspaces`)を予約しておけば、`/workspaces/*` のサブツリー全体が自動的に保護されます。
### ワークスペーススコープのルート
常に `/{slug}/{section}` の下に置きます — `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。ワークスペースのルーティングロジックを絶対に重複させず、共有コードではフレームワーク固有の link API ではなく `useNavigation().push()` を使用してください。
### パッケージとモジュール
モノレポは厳格なパッケージ境界を強制します。
| パッケージ | 依存可能 | 依存禁止 |
| --- | --- | --- |
| `packages/core` | アプリ固有でないもののみ | `react-dom`, `localStorage`, `process.env`, `next/*`, UI ライブラリ |
| `packages/ui` | なし | `@multica/core`, ビジネスロジック |
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
| `apps/web/platform/` | `next/*` | 他のアプリ |
| `apps/desktop/.../platform/` | `react-router-dom`, electron | 他のアプリ |
両方のアプリに同じロジックが現れる場合は、必ず共有パッケージに抽出しなければなりません。「ささいな」重複という例外はありません。
### ファイルとコンポーネント
- ファイル: `kebab-case.tsx` / `kebab-case.ts`(例: `agent-row-actions.tsx`
- コンポーネント: `PascalCase`(例: `AgentRowActions`
- フック: `useCamelCase`(例: `useWorkspaceId`
- テスト: `<file>.test.ts(x)` として同じ場所に配置
- ストアZustand: `<feature>-store.ts`、`use<Feature>Store` として export
### データベースGo + sqlc
- テーブル: `snake_case` の単数形(`user`, `workspace`, `agent_runtime`
- カラム: `snake_case``workspace_id`, `created_at`, `last_seen_at`
- 外部キー: `<table>_id`
- ブール値: `is_<state>` または `<state>_at`(状態変更にはタイムスタンプ形式を推奨)
- マイグレーションファイル: `NNN_descriptive_name.up.sql` + `.down.sql` — 常に双方向を提供する
### Go
- 標準の `gofmt` + `go vet`。例外なし。
- Handler ファイルはドメインを反映する: `agent.go`, `auth.go`, `runtime.go`
- テスト: `<file>_test.go` を同じ場所に配置
- handler での UUID パースは、ルートの `CLAUDE.md` のルールに従ってください — 境界の入力には `parseUUIDOrBadRequest`、信頼できる往復には `parseUUID`panic 版を使い、error を確認せずに `util.ParseUUID` を直接使用しないでください。
### TypeScript
- ネットワーク上の API レスポンスは `snake_case` で、api client が境界で `camelCase` に変換します。TS コード内部では**常に camelCase**。
- 型: `PascalCase``Issue`, `AgentRuntime``IPrefix` は禁止、`_t` サフィックスも禁止。
- 列挙: string literal union を推奨し、ランタイムで反復処理が必要な場合にのみ `enum` を使用。
- TanStack Query のキー: `<feature>/queries.ts` 内のファクトリ関数、例: `issueKeys.detail(id)`。
### イシューキー
すべてのイシューには `MUL-123` のような人が読めるキーがあります。ワークスペースの `issue_prefix`(大文字と数字、通常 3 文字、最大 10 文字)+ 連番です。ワークスペースの admin は Settings → General で接頭辞を変更できますが、変更すると既存のすべてのイシューが番号を振り直されるため、古い接頭辞が埋め込まれた外部参照PR タイトル、ブランチ名、ドキュメントやチャット内のリンク)は解決されなくなります。
### コード内のコメント
英語のみです。リポジトリは Go と TypeScript の両方でこれを強制します。コード内に中国語のコメントを見つけたら、それはバグなので置き換えてください。
### コミットメッセージ
Conventional 形式: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`。意図ごとにまとめられたアトミックなコミット。
---
## 2. i18n 翻訳用語集
これは、すべての翻訳 PR が**必ず**守らなければならない用語集です。かつては `packages/views/locales/glossary.md` にありましたが、そのファイルは現在ここを指す stub です。
### 中核となる区別: エンティティ vs 概念
Multica の製品名詞は 2 つのカテゴリに分かれます。
- **エンティティEntity** — URL、データベースの row、API の型を持ちます。中国語のテキストでは**小文字の英語**で表記し、視覚的に型名のように読めて「これは Multica のシステムエンティティだ」というシグナルを与えます。
- **概念Concept** — 一般名詞であり、データベースのエンティティではありません。**完全に翻訳**し、中国語ユーザーが流れるテキストの中にギザギザの英語を見ないようにします。
このルールは `apps/docs/content/docs/*.zh.mdx` と整合しています — これらのドキュメントは事実上の中国語ボイス標準であり、20 ページ以上で実戦検証されています。
### エンティティ — 混合ルール(`issue` / `skill` / `task`
`issue` / `skill` / `task` は Multica の中核エンティティです。スキーマのカラム、API のフィールド、製品 UI のラベルがすべて英語です。中国語のテキストでは**混合ルール**に従い、何を使うかは単語がどこに現れるかによって変わります。
| 文脈 | 表記 | 例 |
| --- | --- | --- |
| **UI 文字列、状態名、コード参照** | 小文字の英語 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **ドキュメントのタイトル / セクション見出し** | Title-case の英語 **または** 中国語の用語 | "Issue 与 project"、"Skills"、"执行任务" |
| **長文ドキュメントの本文で、エンティティが文の主語になっている場合** | 中国語の用語、初出時に括弧内に英語 | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB フィールド** | 常に `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
中国語の用語の参考:
- `task` ↔ `执行任务`(文脈が明確になれば `任务` に短縮)
- `issue` には定着した中国語訳がありません — 英語のまま;タイトルでは `Issue` のように大文字にできます
- `skill` には定着した中国語訳がありません — 英語のまま;タイトルでは `Skills` のように大文字にできます
**`issue` / `skill` / `task` が `project` / `autopilot` のように中国語へ強制的に翻訳されない理由**:
- **`issue` / `task`**: 開発チームは英語で会話します。中国語の候補("任务" — あいまいすぎて "工作" とほぼ同義;"工单" — IT チケットのニュアンス;"议题" — GitHub 風だが製品の感覚に合わない)はいずれも `issue` よりも読みづらくなります。**ただし**、長文ドキュメントの本文で小文字の `task` を 50 回繰り返すとリズムが崩れるため、本文では `执行任务` を許容しつつ、UI 文字列と状態名は小文字の英語のままにします。
- **`skill`**: 定着した中国語の用語がない Multica 固有の概念です。
- **`project` → "项目"**: 定着した主流の中国語の単語です。Feishu / Tower / Teambition / PingCode / GitHub Projects — すべての中国語製品がこれを翻訳します。中国語の文脈で `project` をそのまま残す製品はありません。
- **`autopilot` → "自动化"**: 中国語で "autopilot" は Tesla の "自动驾驶" を連想させ、この機能が行うことスケジュールに従ってタスクを実行すると合いません。Notion も Feishu も "自动化" を使っており、それが業界の合意です。
### 翻訳しない — ブランドと頭字語
| カテゴリ | 用語 |
| --- | --- |
| ブランド | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
| 頭字語 | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
### 完全に翻訳する — 概念
| English | Chinese |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### 完全に翻訳する — 一般的な UI 用語
| English | Chinese |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱 (label) / 邮件 (action) |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃 (or 启用) / 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### ロールと状態の列挙型(小文字の英語、翻訳しない)
これらはスキーマレベルの識別子です。中国語の文脈でも小文字の英語で表記します。
- ロール: `owner` / `admin` / `member`
- イシューの状態: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
UI ではこれらの値を英語で表示します(必要に応じて `code-style` で囲む)。
- "你需要 owner 权限"
- "已切换到 in_progress"
### 単語の組み合わせルール
英語の単語(エンティティ / ブランド / 頭字語)と周囲の中国語の間には、常に**単一の空白**を入れます。
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### 複数形と数
i18next は `_one` / `_other` を使います。中国語には文法的な数がないため、`_other` のみを埋めます。
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
一般的な数の形式:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### 補間
`{{var}}` を使います。中国語の翻訳では、自然な文章の流れのために順序を並べ替えてもかまいません。
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### 翻訳キーのネーミング
3 階層のネスト: `feature.component.action`。
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
例:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
### Web 専用 / Desktop 専用のコピー
- 共有コピー: namespace JSON の最上位
- Web 専用: `web` セクション
- Desktop 専用: `desktop` セクション
正式な例は `auth.json` を参照してください(`web` セクションに `prefer_desktop` / `desktop_handoff.*` が含まれます)。
---
## 3. 中国語のボイスとスタイル
### 句読点
- 中国語では全角の句読点を使用: `,。:;!?`
- 引用符: 英語の原文に合わせて、まっすぐな二重引用符 `"..."` を使用。`「」` や丸い引用符は使わないでください。
- 省略記号: 単一文字の `…` ではなく、3 つの点 `...`。英語の原文に合わせてください。
- 中国語と英語の混在: 英語の単語の両側にそれぞれ単一の空白(単語の組み合わせルールを参照)。
### スタイルの原則
- **簡潔かつ直接的に。** 翻訳調を避ける: "对于 X 来说"、"作为 X"、"我们的"。
- **エラーメッセージ**: 穏やかだが明確に。"无法保存修改" は "保存修改失败了!" よりも優れています。
- **ボタン**: 動詞を先頭に、2〜4 文字。"取消"、"保存修改"、"立即同步"。
- **ツールチップ**: 完結した短い文。"复制链接到剪贴板"。
- **プレースホルダー**: 例の形式。"输入 issue 标题..."。
### 迷ったときに参照する場所
用語集が特定の用語を扱っていない場合は、次を参照してください。
1. `apps/docs/content/docs/*.zh.mdx` — 事実上の中国語ボイス標準、一貫した翻訳が 20 ページ以上
2. `packages/views/locales/zh-Hans/auth.json` と `editor.json` — JSON 構造 + selector API パターン
3. `packages/views/auth/login-page.tsx` — コンポーネントレベルの selector API 呼び出し箇所
4. `packages/views/settings/components/preferences-tab.tsx` — 言語切り替えの参考
---
## このページを更新するとき
ここのルールを変更した場合は、次も併せて行ってください。
1. 関連する locale JSON / CLAUDE.md / ドキュメントページに適用する
2. PR の説明に変更点を記録し、レビュアーが下流の一括対応を確認できるようにする
このページが契約です。他の何ものもこれを上書きできません。

View File

@@ -1,302 +0,0 @@
---
title: 규약
description: 코드 네이밍, i18n 번역 용어집, 중국어 보이스 가이드의 단일 진실 공급원.
---
이 페이지는 코드 네이밍, i18n 번역 용어집, 중국어 보이스 가이드의 단일 진실 공급원입니다. 예전에 `packages/views/locales/glossary.md`나 여기저기 흩어진 주석에 있던 내용은 이제 모두 이곳에 모여 있습니다.
Multica 코드를 작성하거나, 번역을 변경하거나, 중국어 제품 카피를 작성한다면 이 페이지를 참고하세요.
---
## 1. 코드 네이밍
### 라우트
워크스페이스 진입 전 라우트(사용자가 워크스페이스에 들어가기 전에 존재하는 라우트)는 반드시 단일 단어 또는 `/{noun}/{verb}` 패턴을 사용해야 합니다.
- ✅ `/login`, `/inbox`, `/workspaces/new`
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
루트에 하이픈으로 연결된 단어 묶음은 사용자가 직접 고른 워크스페이스 slug와 충돌하며, 끝없는 예약어 slug 점검을 강요합니다. 명사(`workspaces`)를 예약해 두면 `/workspaces/*` 하위 트리 전체가 자동으로 보호됩니다.
### 워크스페이스 범위 라우트
항상 `/{slug}/{section}` 아래에 둡니다 — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. 워크스페이스 라우팅 로직을 절대 중복하지 말고, 공유 코드에서는 프레임워크별 link API 대신 `useNavigation().push()`를 사용하세요.
### 패키지와 모듈
모노레포는 엄격한 패키지 경계를 강제합니다:
| 패키지 | 의존 가능 | 의존 금지 |
| --- | --- | --- |
| `packages/core` | 앱 종속적이지 않은 것만 | `react-dom`, `localStorage`, `process.env`, `next/*`, UI 라이브러리 |
| `packages/ui` | 없음 | `@multica/core`, 비즈니스 로직 |
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
| `apps/web/platform/` | `next/*` | 다른 앱 |
| `apps/desktop/.../platform/` | `react-router-dom`, electron | 다른 앱 |
두 앱 모두에 동일한 로직이 나타난다면 반드시 공유 패키지로 추출해야 합니다. "사소한" 중복이라는 예외는 없습니다.
### 파일과 컴포넌트
- 파일: `kebab-case.tsx` / `kebab-case.ts` (예: `agent-row-actions.tsx`)
- 컴포넌트: `PascalCase` (예: `AgentRowActions`)
- Hook: `useCamelCase` (예: `useWorkspaceId`)
- 테스트: `<file>.test.ts(x)`로 같은 위치에 배치
- Store (Zustand): `<feature>-store.ts`, `use<Feature>Store`로 export
### 데이터베이스 (Go + sqlc)
- 테이블: `snake_case` 단수 (`user`, `workspace`, `agent_runtime`)
- 컬럼: `snake_case` (`workspace_id`, `created_at`, `last_seen_at`)
- 외래 키: `<table>_id`
- 불리언: `is_<state>` 또는 `<state>_at` (상태 변경에는 타임스탬프 형태 선호)
- 마이그레이션 파일: `NNN_descriptive_name.up.sql` + `.down.sql` — 항상 양방향을 모두 제공
### Go
- 표준 `gofmt` + `go vet`. 예외 없음.
- Handler 파일은 도메인을 반영: `agent.go`, `auth.go`, `runtime.go`
- 테스트: `<file>_test.go`로 같은 위치에 배치
- handler에서의 UUID 파싱은 루트 `CLAUDE.md`의 규칙을 따릅니다 — 경계 입력에는 `parseUUIDOrBadRequest`, 신뢰할 수 있는 왕복에는 `parseUUID`(panic 버전), 절대 error를 확인하지 않고 `util.ParseUUID`를 직접 사용하지 마세요.
### TypeScript
- 네트워크상의 API 응답은 `snake_case`이며, api client가 경계에서 `camelCase`로 변환합니다. TS 코드 내부에서는 **항상 camelCase**.
- 타입: `PascalCase` (`Issue`, `AgentRuntime`); `IPrefix` 금지, `_t` 접미사 금지.
- 열거형: string literal union을 선호하고, 런타임 순회가 필요한 경우에만 `enum`을 사용.
- TanStack Query 키: `<feature>/queries.ts` 안의 팩토리 함수, 예: `issueKeys.detail(id)`.
### 이슈 키
모든 이슈에는 `MUL-123` 같은 사람이 읽을 수 있는 키가 있습니다: 워크스페이스 `issue_prefix`(대문자와 숫자, 보통 3자, 최대 10자) + 일련번호. 워크스페이스 admin은 Settings → General에서 접두사를 변경할 수 있는데, 변경하면 기존의 모든 이슈가 다시 번호 매겨지므로 옛 접두사가 박혀 있는 외부 참조(PR 제목, 브랜치 이름, 문서와 채팅 속 링크)는 더 이상 연결되지 않습니다.
### 코드 내 주석
영어만 사용합니다. 레포는 Go와 TypeScript 모두에 이를 강제합니다. 코드에서 중국어 주석을 발견하면 그것은 버그이니 교체하세요.
### 커밋 메시지
Conventional 형식: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. 의도별로 묶인 원자적 커밋.
---
## 2. i18n 번역 용어집
이것은 모든 번역 PR이 **반드시** 지켜야 하는 용어집입니다. 예전에는 `packages/views/locales/glossary.md`에 있었는데, 그 파일은 이제 이곳을 가리키는 stub입니다.
### 핵심 구분: 엔티티 vs 개념
Multica의 제품 명사는 두 가지 범주로 나뉩니다:
- **엔티티(Entity)** — URL, 데이터베이스 row, API 타입을 가집니다. 중국어 텍스트에서는 **소문자 영문**으로 표기하여 시각적으로 타입 이름처럼 읽히게 하고 "이것은 Multica 시스템 엔티티다"라는 신호를 줍니다.
- **개념(Concept)** — 일반 명사이며, 데이터베이스 엔티티가 아닙니다. **완전히 번역**하여 중국어 사용자가 흐르는 텍스트 속에 들쭉날쭉한 영문을 보지 않도록 합니다.
이 규칙은 `apps/docs/content/docs/*.zh.mdx`와 정렬되어 있습니다 — 이 문서들은 사실상의 중국어 보이스 표준이며 20개 이상의 페이지에서 실전 검증되었습니다.
### 엔티티 — 혼합 규칙 (`issue` / `skill` / `task`)
`issue` / `skill` / `task`는 Multica의 핵심 엔티티입니다. 스키마 컬럼, API 필드, 제품 UI 레이블이 모두 영어입니다. 중국어 텍스트에서는 **혼합 규칙**을 따르며 — 무엇을 사용할지는 단어가 어디에 나타나는지에 따라 달라집니다:
| 맥락 | 표기 | 예시 |
| --- | --- | --- |
| **UI 문자열, 상태 이름, 코드 참조** | 소문자 영문 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **문서 제목 / 섹션 헤딩** | Title-case 영문 **또는** 중국어 용어 | "Issue 与 project"、"Skills"、"执行任务" |
| **장문 문서 산문에서 엔티티가 진행 중인 주어일 때** | 중국어 용어, 첫 언급 시 괄호 안에 영문 | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB 필드** | 항상 `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
중국어 용어 참고:
- `task` ↔ `执行任务` (맥락이 분명해지면 `任务`로 줄여서 사용)
- `issue`에는 정착된 중국어 번역이 없습니다 — 영문 유지; 제목에서는 `Issue`처럼 대문자로 표기 가능
- `skill`에는 정착된 중국어 번역이 없습니다 — 영문 유지; 제목에서는 `Skills`처럼 대문자로 표기 가능
**`issue` / `skill` / `task`가 `project` / `autopilot`처럼 중국어로 강제 번역되지 않는 이유**:
- **`issue` / `task`**: 개발 팀은 영어로 대화합니다. 중국어 후보들("任务" — 너무 모호하고 "工作"과 거의 동의어; "工单" — IT 티켓 어감; "议题" — GitHub 스타일이지만 제품 느낌과 맞지 않음)은 모두 `issue`보다 못하게 읽힙니다. **하지만** 장문 문서 산문에서 소문자 `task`를 50번 반복하면 리듬이 깨지므로 — 산문에서는 `执行任务`를 허용하되, UI 문자열과 상태 이름은 소문자 영문으로 유지합니다.
- **`skill`**: 정착된 중국어 용어가 없는 Multica 고유 개념입니다.
- **`project` → "项目"**: 정착된 주류 중국어 단어. Feishu / Tower / Teambition / PingCode / GitHub Projects — 모든 중국어 제품이 이를 번역합니다. 중국어 맥락에서 `project`를 그대로 두는 제품은 없습니다.
- **`autopilot` → "自动化"**: 중국어에서 "autopilot"은 Tesla의 "自动驾驶"를 연상시키며, 이 기능이 하는 일(일정에 따라 task 실행)과 맞지 않습니다. Notion과 Feishu 모두 "自动化"를 사용하며, 그것이 업계 합의입니다.
### 번역하지 않음 — 브랜드와 약어
| 범주 | 용어 |
| --- | --- |
| 브랜드 | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
| 약어 | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
### 완전히 번역 — 개념
| English | Chinese |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### 완전히 번역 — 일반 UI 단어
| English | Chinese |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱 (label) / 邮件 (action) |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃 (or 启用) / 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### 역할과 상태 열거형 (소문자 영문, 번역하지 않음)
이것들은 스키마 수준의 식별자입니다; 중국어 맥락에서도 소문자 영문으로 표기합니다.
- 역할: `owner` / `admin` / `member`
- 이슈 상태: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
UI에서는 이 값들을 영어로 표시합니다(필요 시 `code-style`로 감쌈):
- "你需要 owner 权限"
- "已切换到 in_progress"
### 단어 조합 규칙
영문 단어(엔티티 / 브랜드 / 약어)와 주변 중국어 사이에는 항상 **단일 공백**을 둡니다:
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### 복수형과 개수
i18next는 `_one` / `_other`를 사용합니다; 중국어에는 문법적 수가 없으므로 `_other`만 채웁니다.
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
일반적인 개수 형식:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### 보간
`{{var}}`를 사용합니다. 중국어 번역은 자연스러운 문장 흐름을 위해 순서를 재배치할 수 있습니다.
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### 번역 키 네이밍
3단계 중첩: `feature.component.action`.
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
예시:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
### Web 전용 / Desktop 전용 카피
- 공유 카피: namespace JSON의 최상위
- Web 전용: `web` 섹션
- Desktop 전용: `desktop` 섹션
정식 예시는 `auth.json`을 참고하세요(`web` 섹션에 `prefer_desktop` / `desktop_handoff.*`가 포함됨).
---
## 3. 중국어 보이스와 스타일
### 구두점
- 중국어에서는 전각 구두점 사용: `,。:;!?`
- 따옴표: 영어 원문과 맞추기 위해 곧은 큰따옴표 `"..."`를 사용. `「」`나 둥근 따옴표는 사용하지 마세요.
- 줄임표: 단일 문자 `…`가 아닌 세 개의 점 `...`. 영어 원문과 일치시키세요.
- 중국어-영어 혼용: 영문 단어 양옆에 각각 단일 공백(단어 조합 규칙 참고).
### 스타일 원칙
- **간결하고 직접적으로.** 번역투 회피: "对于 X 来说"、"作为 X"、"我们的".
- **오류 메시지**: 부드럽지만 명확하게. "无法保存修改"가 "保存修改失败了!"보다 낫습니다.
- **버튼**: 동사를 먼저, 2~4자. "取消"、"保存修改"、"立即同步".
- **툴팁**: 완결된 짧은 문장. "复制链接到剪贴板".
- **플레이스홀더**: 예시 형태. "输入 issue 标题...".
### 막힐 때 참고할 곳
용어집이 특정 용어를 다루지 않을 때는 다음을 참고하세요:
1. `apps/docs/content/docs/*.zh.mdx` — 사실상의 중국어 보이스 표준, 일관된 번역 20개 이상 페이지
2. `packages/views/locales/zh-Hans/auth.json`과 `editor.json` — JSON 구조 + selector API 패턴
3. `packages/views/auth/login-page.tsx` — 컴포넌트 수준 selector API 호출 지점
4. `packages/views/settings/components/preferences-tab.tsx` — 언어 전환기 참고
---
## 이 페이지를 업데이트할 때
이곳의 규칙을 변경하면 다음도 함께 수행하세요:
1. 관련 locale JSON / CLAUDE.md / 문서 페이지에 적용
2. PR 설명에 변경 사항을 기록하여 리뷰어가 다운스트림 정리를 살펴보도록 알리기
이 페이지가 계약입니다; 다른 어떤 것도 이를 무시할 수 없습니다.

View File

@@ -1,4 +0,0 @@
{
"title": "開発者",
"pages": ["conventions"]
}

View File

@@ -1,4 +0,0 @@
{
"title": "Developers",
"pages": ["conventions"]
}

View File

@@ -1,224 +0,0 @@
---
title: 環境変数
description: セルフホストの Multica サーバーを実行するための環境変数の完全な一覧です。
---
import { Callout } from "fumadocs-ui/components/callout";
セルフホストの Multica [サーバー](/self-host-quickstart)は、起動時に環境変数から設定を読み込みます — データベース、サインイン、メール、ストレージ、サインアップ許可リストはすべてここにあります。このページでは、すべての変数を用途別にグループ化しています。各セクションでは、**設定しないと何が起きるか**、そして**プロダクションで必ず設定すべきものはどれか**を明確に説明します。auth 関連の変数を実際にどう設定するかについては、[サインインとサインアップの設定](/auth-setup)を参照してください。
## コアサーバー変数
デプロイ前に必ず検討すべきコア変数です — 一部はサーバーを起動できるようにするデフォルト値を持っていますが、プロダクションでは必須項目を明示的に設定すべきです。
| 変数 | デフォルト | プロダクションで必須? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **はい** |
| `PORT` | `8080` | いいえ(ポートを変更する場合を除く) |
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **はい**(デフォルトは安全ではありません) |
| `APP_ENV` | 空 | **はい**`production` である必要があります) |
| `FRONTEND_ORIGIN` | 空 | **はい**(セルフホストは自身のドメインを設定する必要があります) |
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | いいえ(プロダクションでは必ず空のままにしてください) |
<Callout type="warning">
**プロダクションでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにしてください。** 固定のローカルテストコードはデフォルトで無効になっていますが、`MULTICA_DEV_VERIFICATION_CODE=888888` で有効にすると、`APP_ENV` が production 以外の間は、コードを要求できる誰もがその固定値でサインインできてしまいます。このショートカットは `APP_ENV=production` のときには無視されます。
</Callout>
### データベース接続プール
| 変数 | デフォルト | 説明 |
|---|---|---|
| `DATABASE_MAX_CONNS` | `25` | pgxpool の最大接続数。デーモンは頻繁に3 秒ごとに)ポーリングして接続を使用するため、規模の大きいデプロイではより高い値が必要になる場合があります |
| `DATABASE_MIN_CONNS` | `5` | 最小アイドル接続数 |
**設定しない場合**、上記の値が使われます — 以前プロダクションでプール枯渇を引き起こした pgx 組み込みの 4/NumCPU デフォルトでは**ありません**。
## メール設定
Multica は 2 つの配信バックエンドをサポートします — クラウドデプロイ向けの [Resend](https://resend.com/) と、内部 / オンプレミスネットワーク向けの SMTP relay です。両方が設定されている場合は `SMTP_HOST` が `RESEND_API_KEY` より優先されます。
### Resend
| 変数 | デフォルト | 説明 |
|---|---|---|
| `RESEND_API_KEY` | 空 | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 送信元アドレスResend アカウントで検証済みのドメインである必要があり、SMTP を使用する場合も `From:` ヘッダーとして再利用されます) |
### SMTP relay
| 変数 | デフォルト | 説明 |
|---|---|---|
| `SMTP_HOST` | 空 | SMTP relay のホスト名。これを設定すると SMTP モードが有効になり、Resend を上書きします |
| `SMTP_PORT` | `25` | SMTP ポート。STARTTLS サブミッションには `587` を使用してください。**ポート 465SMTPS / 暗黙的 TLSはサポートされていません** |
| `SMTP_USERNAME` | 空 | SMTP ユーザー名。認証なしの relay の場合は空のままにしてください |
| `SMTP_PASSWORD` | 空 | SMTP パスワード |
| `SMTP_TLS_INSECURE` | `false` | TLS 証明書の検証をスキップするには `true` に設定(プライベート CA / 自己署名証明書のみ) |
サーバーが STARTTLS を通知すると自動的にアップグレードされます。dial タイムアウトは 10 秒で、SMTP セッション全体には 30 秒のデッドラインがあるため、ブラックホール化した relay が auth ハンドラーをハングさせることはできません。
**どちらも設定していない場合の動作**: サーバーはエラーを出しませんが、送信されるはずだったすべてのメール(検証コード、招待リンク)は**サーバーの stdout にのみ記録されます**。ローカル開発には便利です — サーバーログからコードをコピーして使ってください。**プロダクションでこれを設定し忘れると、静かなブラックホールが生まれ**、ユーザーはメールをまったく受け取れず、エラーも一切表面化しません。
## Google OAuth 設定
任意です。メール + 検証コードのみを使用する場合は設定しないままにし、サインインページに「Sign in with Google」を追加する場合は設定してください。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `GOOGLE_CLIENT_ID` | 空 | Google Cloud OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | 空 | Google Cloud OAuth secret |
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth コールバック URLセルフホスト: 自身のフロントエンドドメインに置き換えてください) |
**ランタイムで適用されます**: フロントエンドはこれらの設定をランタイムに `/api/config` 経由で読み込むため、**変更してもフロントエンドのリビルドや再デプロイは不要です** — サーバーを再起動すれば適用されます。
完全なセットアップGoogle Cloud Console の手順を含む)は[サインインとサインアップの設定](/auth-setup#google-oauth-configuration)にあります。
## ファイルストレージ設定
Multica はユーザーがアップロードした添付ファイル(コメント内の画像やファイル)を保存します。**S3 が推奨されます**。S3 が設定されていない場合はローカルディスクにフォールバックします。
### S3 / S3 互換ストレージ
| 変数 | デフォルト | 説明 |
|---|---|---|
| `S3_BUCKET` | 空 | **バケット名のみ**(例: `my-bucket`)。`.s3.<region>.amazonaws.com` サフィックスは含め**ないでください** — サーバーが `S3_BUCKET` + `S3_REGION` から公開ホストを構築します。これを設定すると S3 ストレージが有効になります |
| `S3_REGION` | `us-west-2` | AWS リージョン。バケットの実際のリージョンと一致する必要があります — SDK 署名と公開 URL の構築の両方に使われます |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静的な認証情報。両方を設定しない場合は AWS SDK のデフォルト認証情報チェーンIAM role / 環境認証情報)が使われます |
| `AWS_ENDPOINT_URL` | 空 | カスタムの S3 互換エンドポイント(例: [MinIO](https://min.io/))。これを設定すると path-style URL に切り替わります |
**`S3_BUCKET` を設定しない場合**: サーバーは起動時に `"S3_BUCKET not set, cloud upload disabled"` をログに記録し、すべてのアップロードはローカルディスクにフォールバックします。
**公開 URL** は次の優先順位で構築されます。
1. `CLOUDFRONT_DOMAIN` が設定されている場合は `https://<CLOUDFRONT_DOMAIN>/<key>`。
2. `AWS_ENDPOINT_URL` が設定されている場合は `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`path-style
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`virtual-hosted-style。`S3_BUCKET` にドットが含まれる場合、AWS が発行するワイルドカード TLS 証明書がドットを含むバケットホストを検証できないため、サーバーは `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`path-styleにフォールバックします。
### ローカルディスクS3 が設定されていない場合)
| 変数 | デフォルト | 説明 |
|---|---|---|
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | ローカルストレージのディレクトリ |
| `LOCAL_UPLOAD_BASE_URL` | 空(相対パスを返します) | 公開 base URL — 設定しないとフロントエンドが添付ファイルの完全な URL を解決できません |
### CloudFront任意
S3 の前段に CloudFront を置く場合、3 つの変数が適用されます: `CLOUDFRONT_DOMAIN`、`CLOUDFRONT_KEY_PAIR_ID`、`CLOUDFRONT_PRIVATE_KEY`(または Secrets Manager から読み込むには `CLOUDFRONT_PRIVATE_KEY_SECRET`。CloudFront を使わない場合はスキップしてください — S3 設定とは競合しません。
### Cookie ドメイン
| 変数 | デフォルト | 説明 |
|---|---|---|
| `COOKIE_DOMAIN` | 空 | セッション cookie のスコープ |
- **空**: cookie は訪問した正確なホストでのみ有効です(単一ホストのデプロイに適切)
- **`.example.com` に設定**: cookie がサブドメイン間で共有されます(そのため `app.example.com` と `api.example.com` がサインインセッションを共有します)
- 警告: IP アドレスにはできません(ブラウザは無視します)
## 誰がサインアップできるかを制限する
3 つの許可リストの層が優先順位に従って組み合わされます。**いずれか 1 つの層でも空でない値に設定されると、一致しないメールは拒否されます** — `ALLOW_SIGNUP=true` でさえこれを上書きできません。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `ALLOWED_EMAILS` | 空 | 明示的なメール許可リスト(カンマ区切り)。空でない場合、リストにあるメールのみがサインアップできます |
| `ALLOWED_EMAIL_DOMAINS` | 空 | ドメイン許可リスト(カンマ区切り)。空でない場合、リストにあるドメインのみがサインアップできます |
| `ALLOW_SIGNUP` | `true` | サインアップのマスタースイッチ。サインアップを完全に無効にするには `false` に設定 |
**直感に反する部分**: `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` は「company.io または全員を許可」という意味では**なく**、**company.io のみを許可**という意味です。許可リストの層は AND セマンティクスです — 完全な決定木は[サインインとサインアップの設定 → サインアップ許可リスト](/auth-setup#restricting-who-can-sign-up)にあります。
**招待フロー自体はサインアップ許可リストをチェックしません** — ただし、招待された人は招待を承諾する前に依然として**サインイン**できる必要があります。すでに Multica アカウントを持っている場合(例: 別のワークスペースから)、許可リストの影響を受けずに直接承諾できます。**一度もサインアップしたことがない場合**、サインインの最初のステップ(検証コードの要求)は依然として許可リストのチェックを通過し、`ALLOW_SIGNUP=false` や `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` によって拒否されたメールは**サインアップを完了できず、したがって招待を承諾できません**。
## ワークスペース作成をロックダウンする
`ALLOW_SIGNUP=false` は新しいアカウントをブロックしますが、すでにサインイン済みのユーザーが `POST /api/workspaces` 経由で別のワークスペースを作成することは**ブロックしません**。すべてのイシュー、リポジトリ、エージェントがプラットフォーム管理者に見えなければならないセルフホストインスタンスでは、そのギャップを塞ぐために `DISABLE_WORKSPACE_CREATION=true` を設定してください。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | `true` の場合、`POST /api/workspaces` へのすべての呼び出しが `403 workspace creation is disabled for this instance` を返します。Web UI は `/api/config` 経由ですべての「ワークスペース作成」要素を非表示にします。役割 / owner の例外はありません — このゲートはインスタンス単位で全体に適用されます |
推奨されるブートストラップ手順:
1. `DISABLE_WORKSPACE_CREATION` を設定しないまま(デフォルト)インスタンスを起動します。
2. 管理者としてサインインし、共有ワークスペースを作成します。
3. `DISABLE_WORKSPACE_CREATION=true` を設定してバックエンドを再起動します。この時点から、ユーザーは招待によってのみ参加できます。
招待されたユーザーが最初の検証コードでサインアップを完了できるよう `ALLOW_SIGNUP=true` を維持したい場合は、`DISABLE_WORKSPACE_CREATION=true` を `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` と組み合わせて、どのアドレスがサインアップできるかの範囲を指定してください。`ALLOW_SIGNUP=false` を設定すると、保留中の招待対象者がアカウントを作成すること自体も追加でブロックされます — すべてのメンバーがすでに Multica アカウントを持っているインスタンスでのみ有用です。
## レート制限(任意の Redis
公開 auth エンドポイント — `/auth/send-code`、`/auth/verify-code`、`/auth/google` — の前段には、IP ごとの固定ウィンドウのレート制限があります。リミッターは Redis によって支えられています。`REDIS_URL` を設定しない場合、ミドルウェアは **no-op**fail-openになり、バックエンドは起動時に `rate limiting disabled: REDIS_URL not configured` をログに記録します。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 接続 URL例: `redis://localhost:6379/0`)。設定しないと auth エンドポイントのレート制限が無効になります。同じ Redis はリアルタイムハブの fan-out、PAT キャッシュ、デーモントークンキャッシュでも使われます — 設定しない場合はすべてインメモリ / 直接 DB モードにフォールバックします |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` および `/auth/google` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code` に対する IP あたり毎分の最大リクエスト数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | リミッターがその `X-Forwarded-For` ヘッダーを信頼することを許可する、カンマ区切りの CIDR。空デフォルトは **XFF を決して信頼しない**ことを意味します — リミッターは直接接続の `RemoteAddr` のみを使用します |
リクエストが制限を超えると、サーバーは `429 Too Many Requests`、`Retry-After: 60`、そして本文 `{"error":"too many requests"}` で応答します。
<Callout type="warning">
**リバースプロキシの背後では `RATE_LIMIT_TRUSTED_PROXIES` を必ず設定する必要があります。** そうしないと、バックエンドの観点ではすべての実際のユーザーがプロキシの IP を共有することになり、デプロイ全体が 1 つのバケットに入り、`/auth/send-code` がサイト全体で毎分 5 リクエストになってしまいます。一般的な値: 同一ホストの Caddy / Nginx には `127.0.0.1/32,::1/128`、Cloudflare / ALB / CloudFront には該当 CDN が公開している IP 範囲。`RemoteAddr` がこれらの CIDR のいずれかに含まれる IP のみが、`X-Forwarded-For` を使ってクライアントを識別できます。
</Callout>
この独立した `RATE_LIMIT_TRUSTED_PROXIES` は、オートパイロット webhook リミッター(`/api/webhooks/autopilots/{token}`)を制御する `MULTICA_TRUSTED_PROXIES` とは**異なります**。各リミッターは自身のリストをパースするため、プロキシの背後にあるデプロイは両方を設定すべきです。
## デーモンのチューニングパラメータ
デーモンはユーザーのローカルマシン上で実行され、その設定もローカル環境変数から読み込まれます。一般的なものは次のとおりです。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | サーバーアドレス(セルフホスト: 自身のドメインに置き換えてください) |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | ハートビート間隔 |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | タスクのポーリング間隔 |
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 最大同時タスク数 |
| `MULTICA_<PROVIDER>_PATH` | CLI 名に一致 | 各 AI コーディングツールの実行ファイルへのパス(例: `MULTICA_CLAUDE_PATH` |
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI コーディングツールのデフォルトモデル |
各パラメータがデーモンの動作にどう影響するかの完全な説明は、[デーモンとランタイム](/daemon-runtimes)を参照してください。
## フロントエンドのアクセス制御
| 変数 | デフォルト | 説明 |
|---|---|---|
| `FRONTEND_ORIGIN` | 空 | フロントエンドアドレス。招待メールのリンク、CORS 許可リスト、cookie ドメインはすべてこの値から派生します。設定しない場合、招待メールのリンクはホスト型ドメイン `https://app.multica.ai` にフォールバックします — セルフホストはこれを明示的に設定する必要があります |
| `CORS_ALLOWED_ORIGINS` | 空 | 追加で許可する CORS originカンマ区切り |
| `ALLOWED_ORIGINS` | 空 | WebSocket 専用の origin 許可リスト(カンマ区切り)。設定しない場合、フォールバック順序は `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` です |
<Callout type="warning">
**`FRONTEND_ORIGIN` を設定しないと 2 つの静かな失敗が発生します**: (1) 招待メールのリンクが `https://app.multica.ai`(ホスト型ドメイン)を指し、クリックしてもユーザーがセルフホストインスタンスに戻ってこない。(2) WebSocket の Origin チェックが `localhost:3000 / 5173 / 5174` にフォールバックするため、プロダクションデプロイのすべての WebSocket 接続が拒否され、フロントエンドが「リアルタイム更新を受け取れない」ように見える。
</Callout>
## GitHub 連携
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | GitHub App の slug`https://github.com/apps/<slug>` の末尾部分)。設定 → GitHub のインストールボタン URL を構成します |
| `GITHUB_WEBHOOK_SECRET` | 空 | GitHub App に設定した Webhook secret。すべての `pull_request` / `installation` delivery の HMAC-SHA256 検証に使われ、setup コールバックの state token の HMAC キーとしても使われます |
**どちらかが設定されていない場合の動作:**
- 設定 → GitHub の `Connect GitHub` が**無効**になり、admin に「not configured」というヒントを表示します。
- `/api/webhooks/github` エンドポイントは **`503 github webhooks not configured`** を返します — Multica はすべての署名を有効として扱うのではなく、secret なしではイベント処理を拒否します。
**注:** `GITHUB_WEBHOOK_SECRET` はインストールフローの state token の署名キーとして再利用されるため、運用者は secret を 1 つだけ管理すればよいです。これは GitHub App の *Client* secret では**ありません** — Client secret は OAuth 関連であり、この連携では使われません。完全な手順は [GitHub 連携 → セルフホストのセットアップ](/github-integration#self-host-setup)を参照してください。
## 使用量分析
デフォルトでは、サーバーは Multica の公式 PostHog インスタンスにレポートします。オプトアウトするには `ANALYTICS_DISABLED=true` を設定してください。
| 変数 | デフォルト | 説明 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | バックエンド分析を完全に無効にするには `true` に設定 |
| `POSTHOG_API_KEY` | 組み込みのデフォルトキー | 自身の PostHog インスタンスを指す場合に設定 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog をセルフホストする場合は自身のホストに変更 |
## 次へ
- [サインインとサインアップの設定](/auth-setup) — 上記の auth 関連変数を実際にどう設定するか、そして落とし穴がどこにあるか
- [GitHub 連携](/github-integration) — `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` を支える GitHub App をどうセットアップするか
- [トラブルシューティング](/troubleshooting) — よくある設定ミスの症状と対処法
- [デーモンとランタイム](/daemon-runtimes) — `MULTICA_DAEMON_*` パラメータが実際に何をするか

View File

@@ -1,224 +0,0 @@
---
title: 환경 변수
description: 자체 호스팅 Multica 서버를 실행하기 위한 환경 변수 전체 목록입니다.
---
import { Callout } from "fumadocs-ui/components/callout";
자체 호스팅 Multica [서버](/self-host-quickstart)는 시작 시 환경 변수에서 설정을 읽습니다 — 데이터베이스, 로그인, 이메일, 스토리지, 가입 허용 목록이 모두 여기에 있습니다. 이 페이지는 모든 변수를 용도별로 그룹화합니다: 각 섹션은 **설정하지 않으면 어떻게 되는지**, 그리고 **프로덕션에서 반드시 설정해야 하는 변수가 무엇인지**를 명확히 설명합니다. auth 관련 변수를 실제로 어떻게 설정하는지는 [로그인 및 가입 설정](/auth-setup)을 참고하세요.
## 핵심 서버 변수
배포 전에 반드시 고려해야 하는 핵심 변수입니다 — 일부는 서버를 시작할 수 있게 해주는 기본값을 가지고 있지만, 프로덕션에서는 필수 항목을 명시적으로 설정해야 합니다.
| 변수 | 기본값 | 프로덕션 필수? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **예** |
| `PORT` | `8080` | 아니오 (포트를 변경하는 경우 제외) |
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **예** (기본값은 안전하지 않음) |
| `APP_ENV` | 비어 있음 | **예** (`production`이어야 함) |
| `FRONTEND_ORIGIN` | 비어 있음 | **예** (자체 호스팅은 자체 도메인을 설정해야 함) |
| `MULTICA_DEV_VERIFICATION_CODE` | 비어 있음 | 아니오 (프로덕션에서는 반드시 비워 두어야 함) |
<Callout type="warning">
**프로덕션에서는 `MULTICA_DEV_VERIFICATION_CODE`를 비워 두세요.** 고정된 로컬 테스트 코드는 기본적으로 비활성화되어 있지만, `MULTICA_DEV_VERIFICATION_CODE=888888`로 활성화하면 `APP_ENV`가 production이 아닌 동안에는 코드를 요청할 수 있는 누구나 그 고정 값으로 로그인할 수 있습니다. 이 단축 코드는 `APP_ENV=production`일 때 무시됩니다.
</Callout>
### 데이터베이스 커넥션 풀
| 변수 | 기본값 | 설명 |
|---|---|---|
| `DATABASE_MAX_CONNS` | `25` | pgxpool 최대 연결 수. 데몬은 자주(3초마다) 폴링하며 연결을 사용하므로, 규모가 큰 배포에서는 더 높은 값이 필요할 수 있습니다 |
| `DATABASE_MIN_CONNS` | `5` | 최소 유휴 연결 수 |
**설정하지 않으면** 위 값이 사용됩니다 — 이전에 프로덕션에서 풀 고갈을 일으켰던 pgx 내장 기본값 4/NumCPU가 **아닙니다**.
## 이메일 설정
Multica는 두 가지 전송 백엔드를 지원합니다 — 클라우드 배포용 [Resend](https://resend.com/), 또는 내부 / 온프레미스 네트워크용 SMTP relay. 둘 다 설정된 경우 `SMTP_HOST`가 `RESEND_API_KEY`보다 우선합니다.
### Resend
| 변수 | 기본값 | 설명 |
|---|---|---|
| `RESEND_API_KEY` | 비어 있음 | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 발신 주소 (Resend 계정에서 검증된 도메인이어야 하며, SMTP를 사용할 때도 `From:` 헤더로 재사용됨) |
### SMTP relay
| 변수 | 기본값 | 설명 |
|---|---|---|
| `SMTP_HOST` | 비어 있음 | SMTP relay 호스트명. 이를 설정하면 SMTP 모드가 활성화되고 Resend를 덮어씁니다 |
| `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을 사용하세요; **포트 465(SMTPS / 암묵적 TLS)는 지원되지 않습니다** |
| `SMTP_USERNAME` | 비어 있음 | SMTP 사용자명. 인증 없는 relay의 경우 비워 두세요 |
| `SMTP_PASSWORD` | 비어 있음 | SMTP 비밀번호 |
| `SMTP_TLS_INSECURE` | `false` | TLS 인증서 검증을 건너뛰려면 `true`로 설정 (사설 CA / 자체 서명 인증서만 해당) |
서버가 STARTTLS를 알리면 자동으로 업그레이드됩니다. dial 타임아웃은 10초이고 전체 SMTP 세션에는 30초 데드라인이 있어, 블랙홀이 된 relay가 auth 핸들러를 멈추게 할 수 없습니다.
**둘 다 설정하지 않았을 때의 동작**: 서버는 오류를 내지 않지만, 전송되어야 했던 모든 이메일(인증 코드, 초대 링크)은 **서버의 stdout에만 기록됩니다**. 로컬 개발에는 편리합니다 — 서버 로그에서 코드를 복사해 사용하세요; **프로덕션에서는 이를 설정하는 것을 잊으면 조용한 블랙홀이 되어**, 사용자는 이메일을 전혀 받지 못하고 아무런 오류도 드러나지 않습니다.
## Google OAuth 설정
선택 사항입니다. 이메일 + 인증 코드만 사용하려면 설정하지 않은 채로 두고, 로그인 페이지에 "Sign in with Google"을 추가하려면 설정하세요.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `GOOGLE_CLIENT_ID` | 비어 있음 | Google Cloud OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | 비어 있음 | Google Cloud OAuth secret |
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth 콜백 URL (자체 호스팅: 자신의 프론트엔드 도메인으로 교체) |
**런타임에 적용됨**: 프론트엔드는 런타임에 `/api/config`를 통해 이 설정을 읽으므로, **변경해도 프론트엔드 리빌드나 재배포가 필요 없습니다** — 서버를 재시작하면 적용됩니다.
전체 설정(Google Cloud Console 단계 포함)은 [로그인 및 가입 설정](/auth-setup#google-oauth-configuration)에 있습니다.
## 파일 스토리지 설정
Multica는 사용자가 업로드한 첨부 파일(댓글의 이미지와 파일)을 저장합니다. **S3가 권장됩니다**; S3가 설정되어 있지 않으면 로컬 디스크로 폴백합니다.
### S3 / S3 호환 스토리지
| 변수 | 기본값 | 설명 |
|---|---|---|
| `S3_BUCKET` | 비어 있음 | **버킷 이름만** (예: `my-bucket`). `.s3.<region>.amazonaws.com` 접미사를 포함하지 **마세요** — 서버가 `S3_BUCKET` + `S3_REGION`으로 공개 호스트를 구성합니다. 이를 설정하면 S3 스토리지가 활성화됩니다 |
| `S3_REGION` | `us-west-2` | AWS 리전. 버킷의 실제 리전과 일치해야 합니다 — SDK 서명과 공개 URL 구성 모두에 사용됩니다 |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 비어 있음 | 정적 자격 증명. 둘 다 설정하지 않으면 AWS SDK 기본 자격 증명 체인(IAM role / 환경 자격 증명)이 사용됩니다 |
| `AWS_ENDPOINT_URL` | 비어 있음 | 사용자 정의 S3 호환 엔드포인트 (예: [MinIO](https://min.io/)). 이를 설정하면 path-style URL로 전환됩니다 |
**`S3_BUCKET`을 설정하지 않으면**: 서버는 시작 시 `"S3_BUCKET not set, cloud upload disabled"`를 로깅하고, 모든 업로드는 로컬 디스크로 폴백합니다.
**공개 URL**은 다음 우선순위 순서로 구성됩니다:
1. `CLOUDFRONT_DOMAIN`이 설정된 경우 `https://<CLOUDFRONT_DOMAIN>/<key>`.
2. `AWS_ENDPOINT_URL`이 설정된 경우 `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style).
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). `S3_BUCKET`에 점이 포함된 경우, AWS가 발급한 와일드카드 TLS 인증서가 점이 포함된 버킷 호스트를 검증하지 못하므로 서버는 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style)로 폴백합니다.
### 로컬 디스크 (S3가 설정되지 않은 경우)
| 변수 | 기본값 | 설명 |
|---|---|---|
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | 로컬 스토리지 디렉터리 |
| `LOCAL_UPLOAD_BASE_URL` | 비어 있음 (상대 경로 반환) | 공개 base URL — 설정하지 않으면 프론트엔드가 첨부 파일의 전체 URL을 확인할 수 없습니다 |
### CloudFront (선택)
S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLOUDFRONT_DOMAIN`, `CLOUDFRONT_KEY_PAIR_ID`, `CLOUDFRONT_PRIVATE_KEY` (또는 Secrets Manager에서 읽으려면 `CLOUDFRONT_PRIVATE_KEY_SECRET`). CloudFront를 사용하지 않으면 건너뛰세요 — S3 설정과 충돌하지 않습니다.
### Cookie 도메인
| 변수 | 기본값 | 설명 |
|---|---|---|
| `COOKIE_DOMAIN` | 비어 있음 | 세션 cookie의 범위 |
- **비어 있음**: cookie는 방문한 정확한 호스트에서만 유효합니다 (단일 호스트 배포에 적합)
- **`.example.com`으로 설정**: cookie가 하위 도메인 간에 공유됩니다 (그래서 `app.example.com`과 `api.example.com`이 로그인 세션을 공유함)
- 경고: IP 주소일 수 없습니다 (브라우저가 무시함)
## 누가 가입할 수 있는지 제한하기
세 개의 허용 목록 계층이 우선순위에 따라 결합됩니다. **어느 한 계층이라도 비어 있지 않은 값으로 설정되면, 일치하지 않는 이메일은 거부됩니다** — `ALLOW_SIGNUP=true`조차 이를 무시할 수 없습니다.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `ALLOWED_EMAILS` | 비어 있음 | 명시적 이메일 허용 목록 (쉼표로 구분). 비어 있지 않으면 목록에 있는 이메일만 가입할 수 있습니다 |
| `ALLOWED_EMAIL_DOMAINS` | 비어 있음 | 도메인 허용 목록 (쉼표로 구분). 비어 있지 않으면 목록에 있는 도메인만 가입할 수 있습니다 |
| `ALLOW_SIGNUP` | `true` | 가입 마스터 스위치. 가입을 완전히 비활성화하려면 `false`로 설정 |
**직관에 반하는 부분**: `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true`는 "company.io 또는 모두를 허용"한다는 뜻이 **아니라** **company.io만 허용**한다는 뜻입니다. 허용 목록 계층은 AND 시맨틱입니다 — 전체 결정 트리는 [로그인 및 가입 설정 → 가입 허용 목록](/auth-setup#restricting-who-can-sign-up)에 있습니다.
**초대 흐름 자체는 가입 허용 목록을 확인하지 않습니다** — 하지만 초대받은 사람은 초대를 수락하기 전에 여전히 **로그인**할 수 있어야 합니다. 이미 Multica 계정이 있다면(예: 다른 워크스페이스에서) 허용 목록의 영향을 받지 않고 바로 수락할 수 있습니다; **한 번도 가입한 적이 없다면**, 로그인의 첫 단계(인증 코드 요청)는 여전히 허용 목록 확인을 거치며, `ALLOW_SIGNUP=false`나 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`에 의해 거부된 이메일은 **가입을 완료할 수 없고, 따라서 초대를 수락할 수 없습니다**.
## 워크스페이스 생성 잠그기
`ALLOW_SIGNUP=false`는 새 계정을 차단하지만, 이미 로그인한 사용자가 `POST /api/workspaces`를 통해 또 다른 워크스페이스를 생성하는 것은 **차단하지 않습니다**. 모든 이슈, 저장소, 에이전트가 플랫폼 관리자에게 보여야 하는 자체 호스팅 인스턴스에서는, 그 틈을 막기 위해 `DISABLE_WORKSPACE_CREATION=true`로 설정하세요.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | `true`이면 `POST /api/workspaces`에 대한 모든 호출이 `403 workspace creation is disabled for this instance`를 반환합니다. 웹 UI는 `/api/config`를 통해 모든 "워크스페이스 생성" 요소를 숨깁니다. 역할/owner 예외는 없습니다 — 이 게이트는 인스턴스 단위로 전역 적용됩니다 |
권장 부트스트랩 순서:
1. `DISABLE_WORKSPACE_CREATION`을 설정하지 않은 채로(기본값) 인스턴스를 시작합니다.
2. 관리자로 로그인하여 공유 워크스페이스를 생성합니다.
3. `DISABLE_WORKSPACE_CREATION=true`로 설정하고 백엔드를 재시작합니다. 이 시점부터 사용자는 초대를 통해서만 참여할 수 있습니다.
초대받은 사용자가 첫 인증 코드로 가입을 완료할 수 있도록 `ALLOW_SIGNUP=true`를 유지하고 싶다면, `DISABLE_WORKSPACE_CREATION=true`를 `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`와 결합하여 어떤 주소가 가입할 수 있는지 범위를 지정하세요. `ALLOW_SIGNUP=false`로 설정하면 대기 중인 초대 대상자가 계정을 만드는 것조차 추가로 차단됩니다 — 모든 멤버가 이미 Multica 계정을 가지고 있는 인스턴스에서만 유용합니다.
## 속도 제한 (선택적 Redis)
공개 auth 엔드포인트 — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — 앞에는 IP별 고정 윈도우 속도 제한이 있습니다. 리미터는 Redis로 뒷받침됩니다. `REDIS_URL`을 설정하지 않으면 미들웨어는 **no-op**(fail-open)이 되고, 백엔드는 시작 시 `rate limiting disabled: REDIS_URL not configured`를 로깅합니다.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `REDIS_URL` | 비어 있음 | Redis 연결 URL (예: `redis://localhost:6379/0`). 설정하지 않으면 auth 엔드포인트의 속도 제한이 비활성화됩니다. 동일한 Redis는 실시간 허브 fan-out, PAT 캐시, 데몬 토큰 캐시에서도 사용됩니다 — 설정하지 않으면 모두 인메모리 / 직접 DB 모드로 폴백합니다 |
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` 및 `/auth/google`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code`에 대한 IP당 분당 최대 요청 수 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 비어 있음 | 리미터가 그 `X-Forwarded-For` 헤더를 신뢰하도록 허용하는, 쉼표로 구분된 CIDR. 비어 있음(기본값)은 **XFF를 절대 신뢰하지 않음**을 의미합니다 — 리미터는 직접 연결의 `RemoteAddr`만 사용합니다 |
요청이 제한을 초과하면 서버는 `429 Too Many Requests`, `Retry-After: 60`, 그리고 본문 `{"error":"too many requests"}`로 응답합니다.
<Callout type="warning">
**리버스 프록시 뒤에서는 `RATE_LIMIT_TRUSTED_PROXIES`를 반드시 설정해야 합니다.** 그렇지 않으면 백엔드 관점에서 모든 실제 사용자가 프록시의 IP를 공유하게 되어, 배포 전체가 하나의 버킷에 들어가고, `/auth/send-code`가 사이트 전체에 대해 분당 5회가 됩니다. 일반적인 값: 같은 호스트의 Caddy / Nginx에는 `127.0.0.1/32,::1/128`; Cloudflare / ALB / CloudFront에는 해당 CDN의 공개된 IP 범위. `RemoteAddr`이 이 CIDR 중 하나에 속하는 IP만 `X-Forwarded-For`를 사용해 클라이언트를 식별할 수 있습니다.
</Callout>
이 별도의 `RATE_LIMIT_TRUSTED_PROXIES`는 오토파일럿 webhook 리미터(`/api/webhooks/autopilots/{token}`)를 제어하는 `MULTICA_TRUSTED_PROXIES`와는 **다릅니다**. 각 리미터는 자체 목록을 파싱하므로, 프록시 뒤에 있는 배포는 둘 다 설정해야 합니다.
## 데몬 튜닝 파라미터
데몬은 사용자의 로컬 기기에서 실행되며, 그 설정도 로컬 환경 변수에서 읽습니다. 일반적으로 사용되는 것들:
| 변수 | 기본값 | 설명 |
|---|---|---|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | 서버 주소 (자체 호스팅: 자신의 도메인으로 교체) |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 하트비트 간격 |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 작업 폴링 간격 |
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 최대 동시 작업 수 |
| `MULTICA_<PROVIDER>_PATH` | CLI 이름과 일치 | 각 AI 코딩 도구 실행 파일의 경로 (예: `MULTICA_CLAUDE_PATH`) |
| `MULTICA_<PROVIDER>_MODEL` | 비어 있음 | 각 AI 코딩 도구의 기본 모델 |
각 파라미터가 데몬 동작에 어떻게 영향을 미치는지에 대한 전체 설명은 [데몬과 런타임](/daemon-runtimes)을 참고하세요.
## 프론트엔드 액세스 제어
| 변수 | 기본값 | 설명 |
|---|---|---|
| `FRONTEND_ORIGIN` | 비어 있음 | 프론트엔드 주소. 초대 이메일 링크, CORS 허용 목록, cookie 도메인이 모두 이 값에서 파생됩니다. 설정하지 않으면 초대 이메일 링크는 호스팅 도메인 `https://app.multica.ai`로 폴백합니다 — 자체 호스팅은 이를 명시적으로 설정해야 합니다 |
| `CORS_ALLOWED_ORIGINS` | 비어 있음 | 추가로 허용할 CORS origin (쉼표로 구분) |
| `ALLOWED_ORIGINS` | 비어 있음 | WebSocket 전용 origin 허용 목록 (쉼표로 구분); 설정하지 않으면 폴백 순서는 `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174`입니다 |
<Callout type="warning">
**`FRONTEND_ORIGIN`을 설정하지 않으면 두 가지 조용한 실패가 발생합니다**: (1) 초대 이메일 링크가 `https://app.multica.ai`(호스팅 도메인)를 가리켜, 클릭해도 사용자가 자체 호스팅 인스턴스로 돌아오지 않습니다; (2) WebSocket Origin 검사가 `localhost:3000 / 5173 / 5174`로 폴백하여, 프로덕션 배포의 모든 WebSocket 연결이 거부되고 프론트엔드가 "실시간 업데이트를 받지 못하는" 것처럼 보입니다.
</Callout>
## GitHub 연동
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `GITHUB_APP_SLUG` | 비어 있음 | GitHub App의 slug (`https://github.com/apps/<slug>`의 끝부분). 설정 → GitHub 설치 버튼 URL을 구성합니다 |
| `GITHUB_WEBHOOK_SECRET` | 비어 있음 | GitHub App에 설정한 Webhook secret. 모든 `pull_request` / `installation` delivery의 HMAC-SHA256 검증에 사용되며, setup 콜백 state token의 HMAC 키로도 사용됩니다 |
**둘 중 하나라도 설정하지 않았을 때의 동작:**
- 설정 → GitHub의 `Connect GitHub`가 **비활성화**되고 admin에게 "not configured" 힌트를 표시합니다.
- `/api/webhooks/github` 엔드포인트는 **`503 github webhooks not configured`**를 반환합니다 — Multica는 모든 서명을 유효한 것으로 취급하기보다, secret 없이는 이벤트 처리를 거부합니다.
**참고:** `GITHUB_WEBHOOK_SECRET`은 설치 흐름 state token의 서명 키로 재사용되므로, 운영자는 secret 하나만 관리하면 됩니다. 이것은 GitHub App의 *Client* secret이 **아닙니다** — Client secret은 OAuth 관련이며 이 연동에서는 사용되지 않습니다. 전체 안내는 [GitHub 연동 → 자체 호스팅 설정](/github-integration#self-host-setup)을 참고하세요.
## 사용량 분석
기본적으로 서버는 Multica의 공식 PostHog 인스턴스로 보고합니다. 옵트아웃하려면 `ANALYTICS_DISABLED=true`로 설정하세요.
| 변수 | 기본값 | 설명 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | 백엔드 분석을 완전히 비활성화하려면 `true`로 설정 |
| `POSTHOG_API_KEY` | 내장 기본 키 | 자신의 PostHog 인스턴스를 가리킬 때 설정 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog를 자체 호스팅하는 경우 자신의 호스트로 변경 |
## 다음
- [로그인 및 가입 설정](/auth-setup) — 위의 auth 관련 변수를 실제로 어떻게 설정하는지, 그리고 함정이 어디에 있는지
- [GitHub 연동](/github-integration) — `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`을 뒷받침하는 GitHub App을 어떻게 설정하는지
- [문제 해결](/troubleshooting) — 흔한 설정 오류의 증상과 해결책
- [데몬과 런타임](/daemon-runtimes) — `MULTICA_DAEMON_*` 파라미터가 실제로 하는 일

View File

@@ -49,10 +49,9 @@ Multica supports two delivery backends — [Resend](https://resend.com/) for clo
| Variable | Default | Description |
|---|---|---|
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission, or `465` for SMTPS (implicit TLS, auto-enabled) |
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
| `SMTP_PASSWORD` | empty | SMTP password |
| `SMTP_TLS` | `starttls` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces an immediate TLS handshake on connect (SMTPS); port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS after connect |
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
@@ -129,22 +128,6 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Locking down workspace creation
`ALLOW_SIGNUP=false` blocks new accounts, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue, repo, and agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap.
| Variable | Default | Description |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | When `true`, every call to `POST /api/workspaces` returns `403 workspace creation is disabled for this instance`. The web UI hides every "Create workspace" affordance via `/api/config`. There is no role/owner exception — the gate is global per instance |
Recommended bootstrap sequence:
1. Start the instance with `DISABLE_WORKSPACE_CREATION` unset (the default).
2. Sign in as the admin and create the shared workspace.
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. From this point on, users join via invitation only.
If you also want to keep `ALLOW_SIGNUP=true` so invited users can finish signup with their first verification code, combine `DISABLE_WORKSPACE_CREATION=true` with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` to scope which addresses can sign up. Setting `ALLOW_SIGNUP=false` will additionally block pending invitees from creating their account at all — useful only on instances where every member already has a Multica account.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
@@ -184,8 +167,6 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
| Variable | Default | Description |
|---|---|---|
| `FRONTEND_ORIGIN` | empty | Frontend address. Invite email links, the CORS allowlist, and the cookie domain are all derived from this. When unset, invite email links fall back to the hosted domain `https://app.multica.ai` — self-host must set this explicitly |
| `MULTICA_APP_URL` | empty | Frontend URL for CLI login flow. Also used by the web UI to show self-host daemon setup commands with your app domain; for same-origin deployments this is also used as daemon `server_url` when `MULTICA_PUBLIC_URL` is unset |
| `MULTICA_PUBLIC_URL` | empty | Public API URL, without trailing slash. Used for autopilot webhook URLs and by the web UI as the daemon `server_url` |
| `CORS_ALLOWED_ORIGINS` | empty | Additional allowed CORS origins (comma-separated) |
| `ALLOWED_ORIGINS` | empty | WebSocket-specific origin allowlist (comma-separated); when unset, fallback order is `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` |

View File

@@ -49,10 +49,9 @@ Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`SMTPS隐式 TLS,自动启用)用 `465` |
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`**暂不支持 465SMTPS / 隐式 TLS** |
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
| `SMTP_TLS` | `starttls` | TLS 模式。`implicit`(别名 `smtps`、`ssl`)在连接时立即进行 TLS 握手SMTPS`465` 端口会自动启用。未设置 / `starttls` 则在连接后通过 STARTTLS 升级 |
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s整个 SMTP 会话有 30s deadline避免 relay 黑洞把 auth handler 挂死。
@@ -129,22 +128,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 锁死工作区创建
`ALLOW_SIGNUP=false` 能挡住新注册,但**挡不住**已经登录的用户继续 `POST /api/workspaces` 自助开新工作区。在希望平台管理员能看到全部 issue / 仓库 / 智能体的自部署实例里,把 `DISABLE_WORKSPACE_CREATION=true` 打开以堵上这个口子。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `DISABLE_WORKSPACE_CREATION` | `false` | 设为 `true` 时,对 `POST /api/workspaces` 的任何调用都返回 `403 workspace creation is disabled for this instance`。Web UI 通过 `/api/config` 隐藏所有「创建工作区」入口。该开关是实例级的,没有 owner / admin 例外 |
推荐的开服流程:
1. 启动实例时保持 `DISABLE_WORKSPACE_CREATION` 未设置(默认)。
2. 管理员登录并创建共享工作区。
3. 把 `DISABLE_WORKSPACE_CREATION=true` 设上并重启 backend。之后新用户只能通过邀请加入。
如果还想让被邀请的新用户能完成首次注册,保留 `ALLOW_SIGNUP=true`(必要时用 `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` 限定可注册邮箱),只把 `DISABLE_WORKSPACE_CREATION=true` 打开即可;如果同时设 `ALLOW_SIGNUP=false`,连有 pending invite 的邮箱都无法完成首次注册,仅适合所有成员都已有 Multica 账号的实例。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
@@ -184,8 +167,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `FRONTEND_ORIGIN` | 空 | 前端地址。邀请邮件里的链接、CORS 白名单、cookie domain 都从这里推导。邮件链接在不设时会 fallback 到托管版域名 `https://app.multica.ai`——self-host 必须显式填 |
| `MULTICA_APP_URL` | 空 | CLI 登录流程使用的前端 URL。Web UI 也会用它显示带你自己 app domain 的 self-host 守护进程 setup 命令;同源部署中,如果 `MULTICA_PUBLIC_URL` 未设置,它也会作为守护进程的 `server_url` |
| `MULTICA_PUBLIC_URL` | 空 | 公开 API URL不带结尾 slash。用于 autopilot webhook URL也会被 Web UI 用作守护进程的 `server_url` |
| `CORS_ALLOWED_ORIGINS` | 空 | 额外的 CORS 允许来源(逗号分隔)|
| `ALLOWED_ORIGINS` | 空 | WebSocket 专用的 origin 白名单(逗号分隔);不设就按 `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` 顺序回落 |

View File

@@ -133,18 +133,6 @@ Alternatively, configure step by step: `multica config set server_url http://loc
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
Pick one supported path before relying on the Usage / Runtime dashboard:
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Stopping Services
```bash

Some files were not shown because too many files have changed in this diff Show More