mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Permission mode is a per-session runtime choice, while scheduled tasks cannot rely on a human approval loop. This removes the General settings permission surface, persists session permission metadata, and forces scheduled tasks to run with bypass permissions. Runtime permission changes now handle both directions across bypass boundaries, including startup/prewarm races, by persisting first and restarting only when the CLI launch mode must change. Constraint: Scheduled tasks must be able to execute without a human standing by for authorization. Constraint: The CLI only honors bypass permissions when launched with the skip-permissions flag, so switching to or from bypass requires a restart. Rejected: Keep a global General permission default | it can leak across sessions and scheduled runs in ways the user cannot reason about. Confidence: high Scope-risk: broad Directive: Do not reintroduce a global UI permission selector without proving it cannot affect unrelated sessions or automations. Tested: bun test src/server/__tests__/conversations.test.ts -t "permission" --timeout 30000 (10 pass, 0 fail) Tested: bun run check:server (858 pass, 0 fail) Tested: cd desktop && bun run check:desktop (760 tests plus production build passed) Tested: desktop/scripts/build-macos-arm64.sh and codesign verification passed Tested: Real DeepSeek smoke validated plan, bypass, and scheduled task permission behavior Not-tested: Did not repeat the full DeepSeek smoke after the final startup-race hardening; mock WebSocket permission regression and full server gate were rerun after that change. Fixes #632
257 lines
8.6 KiB
TypeScript
257 lines
8.6 KiB
TypeScript
import { create } from 'zustand'
|
|
import {
|
|
sessionsApi,
|
|
type BatchDeleteSessionsResponse,
|
|
type BranchSessionResponse,
|
|
type CreateSessionRepositoryOptions,
|
|
} from '../api/sessions'
|
|
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
|
import { useTabStore } from './tabStore'
|
|
import type { SessionListItem } from '../types/session'
|
|
import type { PermissionMode } from '../types/settings'
|
|
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
|
|
|
type CreateSessionOptions = {
|
|
repository?: CreateSessionRepositoryOptions
|
|
permissionMode?: PermissionMode
|
|
}
|
|
|
|
type BranchSessionResult = Pick<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
|
|
|
|
type SessionStore = {
|
|
sessions: SessionListItem[]
|
|
activeSessionId: string | null
|
|
isLoading: boolean
|
|
error: string | null
|
|
isBatchMode: boolean
|
|
selectedSessionIds: Set<string>
|
|
|
|
fetchSessions: (project?: string) => Promise<void>
|
|
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
|
|
branchSession: (
|
|
sourceSessionId: string,
|
|
targetMessageId: string,
|
|
options?: { title?: string },
|
|
) => Promise<BranchSessionResult>
|
|
deleteSession: (id: string) => Promise<void>
|
|
deleteSessions: (ids: string[]) => Promise<BatchDeleteSessionsResponse>
|
|
enterBatchMode: () => void
|
|
exitBatchMode: () => void
|
|
toggleSessionSelected: (id: string) => void
|
|
selectSessions: (ids: string[]) => void
|
|
deselectSessions: (ids: string[]) => void
|
|
clearSessionSelection: () => void
|
|
renameSession: (id: string, title: string) => Promise<void>
|
|
updateSessionTitle: (id: string, title: string) => void
|
|
updateSessionPermissionMode: (id: string, mode: PermissionMode) => void
|
|
setActiveSession: (id: string | null) => void
|
|
}
|
|
|
|
export const useSessionStore = create<SessionStore>((set, get) => ({
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
isBatchMode: false,
|
|
selectedSessionIds: new Set(),
|
|
|
|
fetchSessions: async (project?: string) => {
|
|
set({ isLoading: true, error: null })
|
|
try {
|
|
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
|
|
let syncedSessions: SessionListItem[] = []
|
|
set((state) => {
|
|
const currentById = new Map(state.sessions.map((session) => [session.id, session]))
|
|
// Deduplicate by session ID - keep the most recently modified entry.
|
|
const byId = new Map<string, SessionListItem>()
|
|
for (const s of raw) {
|
|
const current = currentById.get(s.id)
|
|
const candidate = preserveLocalTitle(current, s)
|
|
const existing = byId.get(s.id)
|
|
if (!existing || new Date(candidate.modifiedAt) > new Date(existing.modifiedAt)) {
|
|
byId.set(s.id, candidate)
|
|
}
|
|
}
|
|
const sessions = [...byId.values()]
|
|
syncedSessions = sessions
|
|
return { sessions, isLoading: false }
|
|
})
|
|
syncOpenSessionTabTitles(syncedSessions)
|
|
} catch (err) {
|
|
set({ error: (err as Error).message, isLoading: false })
|
|
}
|
|
},
|
|
|
|
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
|
|
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
|
|
...(workDir ? { workDir } : {}),
|
|
...(options?.repository ? { repository: options.repository } : {}),
|
|
...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}),
|
|
})
|
|
const now = new Date().toISOString()
|
|
const optimisticSession: SessionListItem = {
|
|
id,
|
|
title: 'New Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 0,
|
|
projectPath: '',
|
|
workDir: resolvedWorkDir ?? workDir ?? null,
|
|
projectRoot: resolvedWorkDir ?? workDir ?? null,
|
|
workDirExists: true,
|
|
permissionMode: options?.permissionMode,
|
|
}
|
|
|
|
set((state) => ({
|
|
sessions: state.sessions.some((session) => session.id === id)
|
|
? state.sessions
|
|
: [optimisticSession, ...state.sessions],
|
|
activeSessionId: id,
|
|
}))
|
|
|
|
void get().fetchSessions()
|
|
return id
|
|
},
|
|
|
|
branchSession: async (sourceSessionId, targetMessageId, options) => {
|
|
const result = await sessionsApi.branch(sourceSessionId, {
|
|
targetMessageId,
|
|
...(options?.title ? { title: options.title } : {}),
|
|
})
|
|
const sourceSession = get().sessions.find((session) => session.id === sourceSessionId)
|
|
const now = new Date().toISOString()
|
|
const optimisticSession: SessionListItem = {
|
|
id: result.sessionId,
|
|
title: result.title || 'New Session',
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
messageCount: 0,
|
|
projectPath: sourceSession?.projectPath ?? '',
|
|
projectRoot: sourceSession?.projectRoot ?? sourceSession?.workDir ?? result.workDir ?? null,
|
|
workDir: result.workDir ?? sourceSession?.workDir ?? null,
|
|
workDirExists: true,
|
|
}
|
|
|
|
set((state) => ({
|
|
sessions: state.sessions.some((session) => session.id === result.sessionId)
|
|
? state.sessions.map((session) =>
|
|
session.id === result.sessionId
|
|
? { ...session, ...optimisticSession }
|
|
: session)
|
|
: [optimisticSession, ...state.sessions],
|
|
activeSessionId: result.sessionId,
|
|
}))
|
|
|
|
void get().fetchSessions()
|
|
return {
|
|
sessionId: result.sessionId,
|
|
title: result.title,
|
|
workDir: result.workDir,
|
|
}
|
|
},
|
|
|
|
deleteSession: async (id: string) => {
|
|
await sessionsApi.delete(id)
|
|
useSessionRuntimeStore.getState().clearSelection(id)
|
|
set((s) => ({
|
|
sessions: s.sessions.filter((session) => session.id !== id),
|
|
activeSessionId: s.activeSessionId === id ? null : s.activeSessionId,
|
|
selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, [id]),
|
|
}))
|
|
},
|
|
|
|
deleteSessions: async (ids: string[]) => {
|
|
const sessionIds = [...new Set(ids)].filter(Boolean)
|
|
const result = await sessionsApi.batchDelete(sessionIds)
|
|
for (const id of result.successes) {
|
|
useSessionRuntimeStore.getState().clearSelection(id)
|
|
}
|
|
set((s) => ({
|
|
sessions: s.sessions.filter((session) => !result.successes.includes(session.id)),
|
|
activeSessionId: s.activeSessionId && result.successes.includes(s.activeSessionId)
|
|
? null
|
|
: s.activeSessionId,
|
|
selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, result.successes),
|
|
}))
|
|
return result
|
|
},
|
|
|
|
enterBatchMode: () => set({ isBatchMode: true }),
|
|
exitBatchMode: () => set({ isBatchMode: false, selectedSessionIds: new Set() }),
|
|
toggleSessionSelected: (id) => set((s) => {
|
|
const selectedSessionIds = new Set(s.selectedSessionIds)
|
|
if (selectedSessionIds.has(id)) {
|
|
selectedSessionIds.delete(id)
|
|
} else {
|
|
selectedSessionIds.add(id)
|
|
}
|
|
return { selectedSessionIds }
|
|
}),
|
|
selectSessions: (ids) => set((s) => {
|
|
const selectedSessionIds = new Set(s.selectedSessionIds)
|
|
for (const id of ids) selectedSessionIds.add(id)
|
|
return { selectedSessionIds }
|
|
}),
|
|
deselectSessions: (ids) => set((s) => ({
|
|
selectedSessionIds: removeIdsFromSet(s.selectedSessionIds, ids),
|
|
})),
|
|
clearSessionSelection: () => set({ selectedSessionIds: new Set() }),
|
|
|
|
renameSession: async (id: string, title: string) => {
|
|
await sessionsApi.rename(id, title)
|
|
set((s) => ({
|
|
sessions: s.sessions.map((session) =>
|
|
session.id === id ? { ...session, title } : session,
|
|
),
|
|
}))
|
|
},
|
|
|
|
updateSessionTitle: (id, title) => {
|
|
set((s) => ({
|
|
sessions: s.sessions.map((session) =>
|
|
session.id === id ? { ...session, title } : session,
|
|
),
|
|
}))
|
|
},
|
|
|
|
updateSessionPermissionMode: (id, mode) => {
|
|
set((s) => ({
|
|
sessions: s.sessions.map((session) =>
|
|
session.id === id ? { ...session, permissionMode: mode } : session,
|
|
),
|
|
}))
|
|
},
|
|
|
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
|
}))
|
|
|
|
function removeIdsFromSet(selected: Set<string>, ids: string[]): Set<string> {
|
|
if (ids.length === 0) return selected
|
|
const next = new Set(selected)
|
|
for (const id of ids) next.delete(id)
|
|
return next
|
|
}
|
|
|
|
function preserveLocalTitle(
|
|
current: SessionListItem | undefined,
|
|
incoming: SessionListItem,
|
|
): SessionListItem {
|
|
if (!current) return incoming
|
|
if (isPlaceholderSessionTitle(incoming.title) && !isPlaceholderSessionTitle(current.title)) {
|
|
return { ...incoming, title: current.title }
|
|
}
|
|
return incoming
|
|
}
|
|
|
|
function syncOpenSessionTabTitles(sessions: SessionListItem[]): void {
|
|
const titleById = new Map(sessions.map((session) => [session.id, session.title]))
|
|
const { tabs, updateTabTitle } = useTabStore.getState()
|
|
for (const tab of tabs) {
|
|
if (tab.type !== 'session') continue
|
|
const title = titleById.get(tab.sessionId)
|
|
if (title && title !== tab.title) {
|
|
updateTabTitle(tab.sessionId, title)
|
|
}
|
|
}
|
|
}
|