Gate Agents view behind Experimental (#2182)

This commit is contained in:
Neil 2026-05-17 23:26:09 -07:00 committed by GitHub
parent 178e885019
commit 20389bf02d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 130 additions and 50 deletions

View File

@ -222,7 +222,8 @@ describe('Store', () => {
expect(settings.showTasksButton).toBe(true)
expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear'])
expect(settings.openInApplications).toEqual([])
expect(settings.experimentalActivity).toBe(true)
expect(settings.experimentalActivity).toBe(false)
expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true)
expect(settings.floatingTerminalEnabled).toBe(true)
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
expect(settings.notifications.customSoundPath).toBeNull()
@ -615,7 +616,8 @@ describe('Store', () => {
expect(store.getSettings().showTasksButton).toBe(true)
expect(store.getSettings().combinedDiffFileTreeVisibleByDefault).toBe(false)
expect(store.getSettings().visibleTaskProviders).toEqual(['github', 'gitlab', 'linear'])
expect(store.getSettings().experimentalActivity).toBe(true)
expect(store.getSettings().experimentalActivity).toBe(false)
expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true)
expect(store.getSettings().notifications.customSoundPath).toBeNull()
// repos should be loaded
expect(store.getRepos()).toHaveLength(1)
@ -1457,12 +1459,32 @@ describe('Store', () => {
expect(store.getSettings().experimentalPet).toBe(true)
})
it('promotes legacy experimentalActivity profiles to default-on', async () => {
it('defaults legacy experimentalActivity profiles off once', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { experimentalActivity: false },
settings: { experimentalActivity: true },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().experimentalActivity).toBe(false)
expect(store.getSettings().experimentalActivityDefaultedOffForAllUsers).toBe(true)
})
it('preserves experimentalActivity after the default-off migration has run', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
experimentalActivity: true,
experimentalActivityDefaultedOffForAllUsers: true
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}

View File

@ -1210,6 +1210,13 @@ export class Store {
const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers
? (parsed.settings?.floatingTerminalEnabled ?? true)
: true
const experimentalActivityDefaultedOffForAllUsers =
parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true
// Why: the Agents view moved back behind Experimental. Flip every
// pre-migration profile off once, then preserve future user opt-ins.
const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers
? (parsed.settings?.experimentalActivity ?? false)
: false
result = {
...defaults,
...parsed,
@ -1221,10 +1228,8 @@ export class Store {
// the old persisted flag forward once so enabled users don't lose it.
experimentalPet:
parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false,
// Why: Activity graduated from its experimental gate. Force the
// legacy flag on so existing profiles and rollback builds see the
// same default-on behavior as fresh installs.
experimentalActivity: true,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,

View File

@ -23,7 +23,7 @@ export function ExperimentalPane({
}: ExperimentalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const showPet = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.pet])
const showActivity = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.activity])
const showAgentsView = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.activity])
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.symlinks
])
@ -69,18 +69,18 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showActivity ? (
{showAgentsView ? (
<SearchableSetting
title="Activity Page"
description="Slack-style worktree activity feed for agent completions and blocking states."
title="Agents View"
description="Threaded left-sidebar feed for agent completions and blocking states."
keywords={EXPERIMENTAL_SEARCH_ENTRY.activity.keywords}
className="space-y-3 px-1 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Activity Page</Label>
<Label>Agents View</Label>
<p className="text-xs text-muted-foreground">
Adds an Activity entry under Tasks with a threaded worktree feed for completed
Adds an Agents entry to the left sidebar with a threaded worktree feed for completed
agents, blocking questions, unread state, and worktree creation events. Experimental
the event model and UI may change.
</p>

View File

@ -16,17 +16,19 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
},
{
title: 'Activity Page',
description: 'Slack-style worktree activity feed for agent completions and blocking states.',
title: 'Agents View',
description: 'Threaded left-sidebar feed for agent completions and blocking states.',
keywords: [
'experimental',
'agents',
'agents view',
'activity',
'notifications',
'agents',
'worktrees',
'timeline',
'unread',
'bell'
'bell',
'sidebar'
]
},
{
@ -61,6 +63,6 @@ function findEntry(title: string): SettingsSearchEntry {
export const EXPERIMENTAL_SEARCH_ENTRY = {
pet: findEntry('Pet'),
activity: findEntry('Activity Page'),
activity: findEntry('Agents View'),
symlinks: findEntry('Symlinks on worktrees')
} as const

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { shouldShowAgentsButton } from './SidebarNav'
describe('SidebarNav', () => {
it('hides the Agents entry while settings are loading', () => {
expect(shouldShowAgentsButton(null)).toBe(false)
})
it('hides the Agents entry while the experimental Agents view is off', () => {
expect(
shouldShowAgentsButton({
...getDefaultSettings('/tmp'),
experimentalActivity: false
})
).toBe(false)
})
it('shows the Agents entry when the experimental Agents view is on', () => {
expect(
shouldShowAgentsButton({
...getDefaultSettings('/tmp'),
experimentalActivity: true
})
).toBe(true)
})
})

View File

@ -4,6 +4,7 @@ import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { GlobalSettings } from '../../../../shared/types'
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
@ -18,6 +19,12 @@ const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('
// rows under that strip should remain clickable when their bounds overlap.
const SIDEBAR_NAV_HIT_TARGET_CLASS = 'relative z-20'
export function shouldShowAgentsButton(
settings: Pick<GlobalSettings, 'experimentalActivity'> | null | undefined
): boolean {
return settings?.experimentalActivity === true
}
const SidebarNav = React.memo(function SidebarNav() {
const openTaskPage = useAppStore((s) => s.openTaskPage)
const openAutomationsPage = useAppStore((s) => s.openAutomationsPage)
@ -38,6 +45,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings))
const preferredVisibleTaskProviders = React.useMemo(
() => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
[rawVisibleTaskProviders]
@ -241,29 +249,31 @@ const SidebarNav = React.memo(function SidebarNav() {
/>
<span className="flex-1">Automations</span>
</button>
<button
type="button"
onClick={openActivityPage}
aria-current={activityActive ? 'page' : undefined}
className={cn(
SIDEBAR_NAV_HIT_TARGET_CLASS,
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
activityActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<Bell
className={cn('size-4 shrink-0', !activityActive && 'text-sidebar-foreground/30')}
strokeWidth={activityActive ? 2.25 : 1.75}
/>
<span className="flex-1">Agents</span>
{activityUnreadCount > 0 ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
{activityUnreadCount}
</span>
) : null}
</button>
{showAgentsButton ? (
<button
type="button"
onClick={openActivityPage}
aria-current={activityActive ? 'page' : undefined}
className={cn(
SIDEBAR_NAV_HIT_TARGET_CLASS,
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
activityActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<Bell
className={cn('size-4 shrink-0', !activityActive && 'text-sidebar-foreground/30')}
strokeWidth={activityActive ? 2.25 : 1.75}
/>
<span className="flex-1">Agents</span>
{activityUnreadCount > 0 ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
{activityUnreadCount}
</span>
) : null}
</button>
) : null}
<button
type="button"
onClick={() => openModal('worktree-palette')}

View File

@ -605,12 +605,16 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
worktreeNavHistoryIndex: nextHistoryIndex
}
}),
openActivityPage: () =>
openActivityPage: () => {
if (get().settings?.experimentalActivity !== true) {
return
}
set((state) => ({
activeView: 'activity',
previousViewBeforeActivity:
state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView
})),
}))
},
closeActivityPage: () =>
set((state) => ({
activeView: state.previousViewBeforeActivity
@ -660,9 +664,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView
})),
closeSettingsPage: () =>
set((state) => ({
activeView: state.previousViewBeforeSettings
})),
set((state) => {
const previousView =
state.previousViewBeforeSettings === 'activity' &&
state.settings?.experimentalActivity !== true
? 'terminal'
: state.previousViewBeforeSettings
return { activeView: previousView }
}),
settingsNavigationTarget: null,
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),

View File

@ -259,7 +259,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// Why: off by default — opt-in cosmetic joke feature. Leaving the default
// false keeps the overlay unmounted for users who never enable it.
experimentalPet: false,
experimentalActivity: true,
experimentalActivity: false,
experimentalActivityDefaultedOffForAllUsers: true,
experimentalWorktreeSymlinks: false,
// Why: local desktop remains the default server until the user explicitly
// selects a saved runtime environment.

View File

@ -197,6 +197,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'openLinksInApp',
'experimentalMobile',
'experimentalPet',
'experimentalActivity',
'experimentalWorktreeSymlinks',
'geminiCliOAuthEnabled'
] as const satisfies readonly BooleanGlobalSettingsKey[]

View File

@ -1579,9 +1579,12 @@ export type GlobalSettings = {
/** Legacy persisted key from before the sidekick -> pet rename. Read only
* during migration; new writes use experimentalPet. */
experimentalSidekick?: boolean
/** Legacy persisted flag from when Activity was experimental. Activity is
* now default-on and this no longer gates the page. */
/** Experimental: left-sidebar Agents view with a threaded feed for agent
* completions, blocking states, unread state, and worktree creation events. */
experimentalActivity: boolean
/** One-shot migration guard for defaulting the Agents view off for all
* users. Once set, later explicit opt-ins persist normally. */
experimentalActivityDefaultedOffForAllUsers?: boolean
/** Experimental: when creating a worktree, automatically symlink a
* user-configured set of files/folders from the primary checkout (e.g.
* `.env`, `node_modules`) into the new worktree. Opt-in while the