Skip to content

feat(sessions): org-scope aware session grouping and workspace picker#402

Merged
Harry19081 merged 8 commits into
developfrom
feat/org-scope-session-grouping
Jul 17, 2026
Merged

feat(sessions): org-scope aware session grouping and workspace picker#402
Harry19081 merged 8 commits into
developfrom
feat/org-scope-session-grouping

Conversation

@Neonforge98

Copy link
Copy Markdown
Collaborator

What

When a cloud org is the active sidebar scope, sessions and the new-session workspace picker now follow the org's repo scope instead of everything defaulting to Personal.

1. Workspace picker filters to the org scope

SessionInfoLine's repo picker (both WorkspaceDropdown and WorkspacePalette, incl. the recent + multi-repo-workspace sections) only offers repos whose git-remote scope key is inside the active org's repo scope. Multi-repo workspaces are judged by their primary folder. A stale out-of-scope selection is swapped to the first in-scope repo, so a session created under an org scope always lands visible in that org — never silently in Personal.

2. New sessions default into the active org

Unchanged autoTagNewSession tags an in-scope new session into the active cloud org; Personal now hides any session that lives in a cloud org (explicit tag OR repo-scope match), so it shows up in the org view and not both.

3. Imported CLI sessions match by repo scope at filter time

Imported claude/codex/cursor sessions carry no persisted org_id. Rather than stamping one org at load time (which forced the multi-org case into Personal), the sidebar matches them against repo scopes at filter time: a session whose repo is covered by an org's scope appears under every covering org's view, and is hidden from Personal as soon as any member org covers it. No exactly-one-org ambiguity rule.

No new polling

All derivation rides lists the sidebar already holds; async git-remote scope-key resolutions re-derive through the shared cache subscription (subscribeShareableScopeKeys). The picker fallback runs on scope/repo-list changes only. Mirrors the focus-adaptive / cache-bounded posture of the recent perf work.

Verification (macOS, computer-use, real app)

Org scope = github.com/yorgai/org2 (the ORGII checkout's remote):

  • Picker: under the cloud-org scope the workspace picker shows only ORGII — every out-of-scope repo/workspace (ORGII-cloud-infra, Brick-Vault, …) is gone. Under Personal the full list returns.
  • New session: a session created under the org scope appears in the org's sidebar list and is absent from Personal.
  • Imported sessions: three Codex history sessions on the ORGII repo auto-appear under the org scope and are hidden from Personal; claude/codex sessions on other repos (journal, rollout-*) stay in Personal. Confirmed the org repoScope exactly matches the ORGII remote.
  • Perf: app idle CPU ~0.1%, RSS ~406MB, no periodic churn.

tsc, eslint, and the TeamCollaboration + sidebar suites (184 tests, incl. 12 new for the scope matchers) are green.

🤖 Generated with Claude Code

Under an active cloud org sidebar scope:

- The new-session workspace picker (WorkspaceDropdown + WorkspacePalette)
  only offers repos whose git-remote scope key falls inside the org's
  repo scope — out-of-scope workspaces can no longer be selected. A stale
  out-of-scope selection (persisted default from another scope) is
  swapped to the first in-scope repo, so a session created under an org
  scope always lands visible in that org (previously it silently fell to
  Personal when autoTag's scope gate failed).

- Personal scope now hides sessions explicitly tagged into a cloud org
  (the mirror of extraSessionIds on the cloud side); untagging via
  MoveToOrgDialog or the engine's out-of-scope invalidation moves them
  back.

- Imported CLI sessions (claude/codex/cursor/…) whose repo sits inside
  exactly ONE member org's repo scope are stamped with that org at
  list-materialization time (in-memory overlay over the existing load
  path) — they group under the org instead of piling up in Personal.
  Ambiguous coverage (multiple orgs) stays Personal.

No new polling anywhere: the picker predicate re-evaluates via the
shared scope-key cache subscription, the fallback guard runs on scope /
repo-list changes only, and the overlay rides the existing list loads
(cache-only peek + fire-and-forget prime).

Pre-commit hook ran. Total eslint: 0, total circular: 0
Replace the load-time single-org stamp with view-layer matching: an
imported CLI session whose repo is covered by an org's repo scope now
appears under EVERY covering org's view (no exactly-one-org ambiguity
rule), and Personal hides it as soon as ANY member org covers it.

The load-time overlay could only write one orgId per session, which
forced the multi-org case to stay in Personal — the wrong default. The
sidebar connector now derives two id-sets per render pass from lists it
already holds (extraSessionIds for the active cloud scope, exclusions
for Personal); async scope-key resolutions re-derive through the shared
cache subscription. loaders.ts returns to untouched pre-feature state.

Pre-commit hook ran. Total eslint: 0, total circular: 1
Multi-repo workspaces and the recent section bypassed the repoFilter.
They are judged by their PRIMARY folder now (the path a session launched
from the workspace reports as repoPath), matching autoTag's semantics.

Pre-commit hook ran. Total eslint: 0, total circular: 0
Three picker-filter corrections from live E2E:

- Scope keys now come from peekShareableScopeKeys (ALL remotes) instead
  of the primary-only peek — a fork checkout whose upstream hits the org
  scope (VantaNode/ORG2 origin + yorgai/ORG2 upstream) was hidden.
- Multi-repo workspaces stay visible when ANY member folder is eligible,
  not just the primary.
- The picker predicate is now OPTIMISTIC for still-resolving checkouts
  (visible + primed until the cache lands); the repos table stores no
  repo_url, so every row depends on async remote resolution and a cold
  cache used to blank genuinely-in-scope repos. Session grouping keeps
  the strict resolved-evidence rule; a scope-less org no longer filters
  the picker at all.

Pre-commit hook ran. Total eslint: 0, total circular: 0
@Neonforge98

Copy link
Copy Markdown
Collaborator Author

Follow-up fix from live E2E with real repo/workspace data (b102332):

  • Any-remote matching: scope keys now come from ALL of a checkout's remotes — a fork checkout (origin VantaNode/ORG2 + upstream yorgai/ORG2) was hidden because only the primary remote was consulted.
  • Any-member workspaces: multi-repo workspaces stay visible when ANY member folder is in scope (was primary-only, which hid ORGII-cloud-infra-ORGII etc.).
  • Optimistic picker rows: the repos store has no repo_url column, so every row depends on async remote resolution; still-resolving rows now stay visible (and primed) instead of being hidden behind a cold cache. Session grouping keeps the strict resolved-evidence rule. A scope-less org no longer filters the picker.

Re-verified on the real app: ORG2 (fork), ORG2-claude_code, ORGII-cloud-infra-ORGII and ORGII Workspace all appear under the org scope; out-of-scope repos/workspaces stay filtered.

Per user decision: a session lives in Personal AND every org whose repo
scope covers it, simultaneously. Org membership no longer auto-removes a
session from Personal.

- MoveToOrgDialog: a "Personal" checkbox (default checked, always enabled,
  local-only — no server sync) sits above the org rows, making the
  multi-select model visible. Unchecking stores PERSONAL_EXCLUDED_TOKEN.
- Personal scope now hides ONLY sessions the user explicitly excluded,
  not everything tagged/scope-matched to an org (reverts the mirror rule).
- Drop the now-unused collectImportedSessionIdsCoveredByAnyScope helper.

The personal marker rides the same tag atom but is not a cloud org, so
cloud parsing/sync skip it.

Pre-commit hook ran. Total eslint: 0, total circular: 0
@Neonforge98

Copy link
Copy Markdown
Collaborator Author

Per user decision (9b52c59): Personal is a default-on membership — a session lives in Personal AND every covering org simultaneously; org membership never auto-removes it from Personal.

  • MoveToOrgDialog gains a "Personal" checkbox (default checked, local-only, instant) above the org rows, making the multi-select model explicit.
  • Unchecking stores a local personal:excluded marker (same tag atom, never synced — cloud parsing skips non-cloud: tokens); Personal scope hides only explicitly-excluded sessions.
  • Reverts the earlier "in an org ⇒ hidden from Personal" mirror rule and drops its helper.

Live-verified: dialog shows Personal + org checked together; unchecking Personal removes the session from the Personal view instantly while it stays in the org view; state persists across dialog reopen; re-checking restores it.

Pre-commit hook ran. Total eslint: 0, total circular: 0
- session creator guard swaps drafts via onRepoSelect only, no longer
  persists an org repo as the global default
- orphan double-guard: Personal cannot be unchecked without another org
  membership; sync engine clears personal:excluded with the last cloud tag
- MoveToOrgDialog truth: personalUnavailable badge for org-stamped
  sessions, autoInScope badge for scope-matched imported sessions
- workspace picker: system-path rows exempt from scope filtering, shared
  workspaceMatchesRepoFilter, eligibility split from search filtering,
  intermediate eligible* memos inlined
- sidebar scope matching dedupes per repoPath and normalizes org scopes
  once per pass; shared useShareableScopeKeyVersion hook
- orgScopeRepoFilter resolves fs_uri (all remotes) before repo_url and
  strips file:// scheme; predicates merged
- drop dead orgId guard + stale header in importedSessionScopeMatch
- strip branch-added narrative comments; translate 3 new i18n keys
  across 13 locales; extend unit tests (engine, tags atom, filters)

Pre-commit hook ran. Total eslint: 0, total circular: 0
Manual org-uncheck and add-rollback in MoveToOrgDialog used raw
withoutTag, so untagging the last org of a Personal-excluded session
orphaned it out of every scope. Both paths now use withoutCloudOrgTag,
which clears personal:excluded with the last cloud tag - a session
always keeps at least one membership.

Also fix Checkbox: checked+disabled rendered as a near-background gray
box indistinguishable from unchecked (which is what made the dialog
look like it had zero memberships). Checked/indeterminate now wins over
disabled in getCheckboxIconClassName, keeping the accent fill dimmed by
the existing label opacity.

Pre-commit hook ran. Total eslint: 0, total circular: 0
@Neonforge98

Copy link
Copy Markdown
Collaborator Author

审计修复 + 归属不变量修复已推送(56cd55c3f, 5d6500f):

审计修复(15 项 finding 全部处理)

  • 新会话 org-scope 守卫改为 onRepoSelect 仅换草稿,不再把 org 仓库持久化成全局默认 repo
  • orgScopeRepoFilter 重写:fs_uri(全部 remotes,含 fork upstream)优先于 repo_url,剥离 file:// 前缀,strict/optimistic 谓词合并为单一 evaluateOrgScope
  • workspace picker:系统路径行豁免 scope 过滤(不再闪烁/对 $HOME 发 git-remote IPC);workspaceMatchesRepoFilter 统一 any-member 规则;eligibility 与搜索过滤拆分 memo,打字不再重算 scope
  • 侧栏导入会话匹配:按 repoPath 去重求值 + org scope 每轮只归一化一次;删除死 orgId 守卫与过期头注释
  • MoveToOrgDialog 徽标真实性:org 创建的会话显示「不属于 Personal」;scope 自动匹配的导入会话显示「已按范围归入」
  • useShareableScopeKeyVersion() 抽取,替换两处新增的 useSyncExternalStore 调用点
  • 分支新增叙述性注释全部清除;3 个新 i18n key 已在 13 个 locale 人工翻译(check:i18n:cloud 通过)

归属不变量(会话任何时刻 ≥1 归属)

  • 双保险:Personal 为唯一归属时复选框禁用(「唯一归属」徽标);引擎和对话框所有移除 cloud 标签的路径统一走 withoutCloudOrgTag——掉落最后一个 org 标签时自动清除 personal:excluded 回落 Personal。全库 grep 确认无遗漏路径
  • 附带修复 Checkbox 组件:checked+disabled 原先渲染成近背景灰、与未勾选无法区分(正是误报「全灭」的根源),现保留强调色按 50% 透明度呈现

验证:tsc 零错误、eslint 干净、vitest 262 用例全过;真机 computer-use 实测「排除 Personal → 移除最后一个 org → 自动回落 Personal(禁用+唯一归属)」链路通过。

@Harry19081
Harry19081 merged commit 6fa2dc4 into develop Jul 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants