Compare commits
5 commits
8e30d9c3cf
...
8464b33de0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8464b33de0 | |||
| 9c2ac65635 | |||
| 7d9d4fa2bc | |||
| 23e70b6f0b | |||
| 4b08855e13 |
15 changed files with 305 additions and 42 deletions
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Session Status Indicators - Design
|
||||||
|
|
||||||
|
Date: 2026-05-30
|
||||||
|
Status: Approved (3-distinct-states)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Sidebar session indicator is an animated green dot driven purely by timestamp
|
||||||
|
(isActive: diffInMinutes < 10 in sidebar/utils/utils.ts). It lights up for any
|
||||||
|
session touched in the last 10 min regardless of whether an agent is running.
|
||||||
|
The real processing state (processingSessions Set, event-driven) is only shown
|
||||||
|
in the chat-area status bar, never in the sidebar.
|
||||||
|
|
||||||
|
## Three States
|
||||||
|
- Processing: processingSessions.has(session.id) -> amber dot, animate-ping halo + solid core. Title "Agent running".
|
||||||
|
- Recently active: sessionView.isActive && !isProcessing -> static sky-blue dot. Title "Recently active".
|
||||||
|
- Idle: neither -> no indicator.
|
||||||
|
|
||||||
|
## Data flow (new threading, parallel to currentTime)
|
||||||
|
AppContent -> useProjectsState({processingSessions}) -> sidebarSharedProps
|
||||||
|
-> Sidebar (SidebarProps) -> projectListProps -> SidebarProjectList
|
||||||
|
-> SidebarProjectItem -> SidebarProjectSessions -> SidebarSessionItem.
|
||||||
|
processingSessions optional in leaf types -> graceful degrade.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
1. app/AppContent.tsx - pass processingSessions into useProjectsState
|
||||||
|
2. hooks/useProjectsState.ts - accept + sidebarSharedProps + deps
|
||||||
|
3. sidebar/types/types.ts - SidebarProps gains processingSessions
|
||||||
|
4. sidebar/view/Sidebar.tsx - destructure + projectListProps
|
||||||
|
5. sidebar/view/subcomponents/SidebarProjectList.tsx - type + passthrough
|
||||||
|
6. sidebar/view/subcomponents/SidebarProjectItem.tsx - type + passthrough
|
||||||
|
7. sidebar/view/subcomponents/SidebarProjectSessions.tsx - type + passthrough
|
||||||
|
8. sidebar/view/subcomponents/SidebarSessionItem.tsx - 3-state indicator
|
||||||
|
|
||||||
|
## Risk: low, additive prop threading + presentational swap. Feature branch, no deploy until reviewed.
|
||||||
|
|
@ -535,13 +535,13 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||||
|
|
||||||
// Dangerously-skip / bypass mode: auto-allow EVERY tool, including interactive
|
|
||||||
// ones (AskUserQuestion, ExitPlanMode). This guarantees zero prompts.
|
|
||||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
|
||||||
return { behavior: 'allow', updatedInput: input };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requiresInteraction) {
|
if (!requiresInteraction) {
|
||||||
|
// Bypass / dangerously-skip: auto-allow normal tools (no prompt). Interactive
|
||||||
|
// tools (AskUserQuestion, ExitPlanMode) are NOT bypassed - they are genuine
|
||||||
|
// requests for user input and must still reach the user and fire a notification.
|
||||||
|
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||||
|
return { behavior: 'allow', updatedInput: input };
|
||||||
|
}
|
||||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||||
matchesToolPermission(entry, toolName, input)
|
matchesToolPermission(entry, toolName, input)
|
||||||
);
|
);
|
||||||
|
|
@ -563,7 +563,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||||
provider: 'claude',
|
provider: 'claude',
|
||||||
sessionId: capturedSessionId || sessionId || null,
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
kind: 'action_required',
|
kind: 'action_required',
|
||||||
code: 'permission.required',
|
code: requiresInteraction ? 'input.required' : 'permission.required',
|
||||||
meta: { toolName, sessionName: sessionSummary },
|
meta: { toolName, sessionName: sessionSummary },
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
requiresUserAction: true,
|
requiresUserAction: true,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ function buildPushBody(event) {
|
||||||
'permission.required': event.meta?.toolName
|
'permission.required': event.meta?.toolName
|
||||||
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
? `Action Required: Tool "${event.meta.toolName}" needs approval`
|
||||||
: 'Action Required: A tool needs your approval',
|
: 'Action Required: A tool needs your approval',
|
||||||
|
'input.required': 'Needs your input: Claude is waiting for an answer',
|
||||||
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
|
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||||
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
|
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
|
||||||
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
|
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ function AppContentInner() {
|
||||||
markSessionAsInactive,
|
markSessionAsInactive,
|
||||||
markSessionAsProcessing,
|
markSessionAsProcessing,
|
||||||
markSessionAsNotProcessing,
|
markSessionAsNotProcessing,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
|
markSessionAsNeedsInput,
|
||||||
|
clearSessionNeedsInput,
|
||||||
} = useSessionProtection();
|
} = useSessionProtection();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -58,8 +62,34 @@ function AppContentInner() {
|
||||||
latestMessage,
|
latestMessage,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Observe the shared websocket stream at the app level so the sidebar can show
|
||||||
|
// a "needs input" state for ANY session (not just the open one). A pending
|
||||||
|
// permission / AskUserQuestion marks the session; resolution clears it.
|
||||||
|
useEffect(() => {
|
||||||
|
const msg = latestMessage as { kind?: string; sessionId?: string | null } | null;
|
||||||
|
if (!msg || !msg.kind) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sid = msg.sessionId || null;
|
||||||
|
if (!sid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.kind === 'permission_request') {
|
||||||
|
markSessionAsNeedsInput(sid);
|
||||||
|
} else if (
|
||||||
|
msg.kind === 'permission_cancelled' ||
|
||||||
|
msg.kind === 'complete' ||
|
||||||
|
msg.kind === 'error'
|
||||||
|
) {
|
||||||
|
clearSessionNeedsInput(sid);
|
||||||
|
}
|
||||||
|
}, [latestMessage, markSessionAsNeedsInput, clearSessionNeedsInput]);
|
||||||
|
|
||||||
usePaletteOpsRegister({
|
usePaletteOpsRegister({
|
||||||
openSettings,
|
openSettings,
|
||||||
refreshProjects: refreshProjectsSilently,
|
refreshProjects: refreshProjectsSilently,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[]
|
||||||
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-session model persistence keys.
|
||||||
|
// Global key (`${provider}-model`) holds the "last used" default for NEW sessions.
|
||||||
|
// Session key (`${provider}-model-${sessionId}`) pins the model for that session.
|
||||||
|
const globalModelKey = (provider: string) => `${provider}-model`;
|
||||||
|
const sessionModelKey = (provider: string, sessionId: string) => `${provider}-model-${sessionId}`;
|
||||||
|
|
||||||
interface UseChatProviderStateArgs {
|
interface UseChatProviderStateArgs {
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
}
|
}
|
||||||
|
|
@ -26,18 +32,21 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
const [provider, setProvider] = useState<LLMProvider>(() => {
|
const [provider, setProvider] = useState<LLMProvider>(() => {
|
||||||
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||||
});
|
});
|
||||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
|
||||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
// Initial model values: prefer the per-session pin if a session is already
|
||||||
});
|
// selected on mount, otherwise the global last-used default.
|
||||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
const initialModel = (p: string, fallback: string) => {
|
||||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
if (selectedSession?.id) {
|
||||||
});
|
const pinned = localStorage.getItem(sessionModelKey(p, selectedSession.id));
|
||||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
if (pinned) return pinned;
|
||||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
}
|
||||||
});
|
return localStorage.getItem(globalModelKey(p)) || fallback;
|
||||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
};
|
||||||
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
|
|
||||||
});
|
const [cursorModel, setCursorModelState] = useState<string>(() => initialModel('cursor', CURSOR_MODELS.DEFAULT));
|
||||||
|
const [claudeModel, setClaudeModelState] = useState<string>(() => initialModel('claude', CLAUDE_MODELS.DEFAULT));
|
||||||
|
const [codexModel, setCodexModelState] = useState<string>(() => initialModel('codex', CODEX_MODELS.DEFAULT));
|
||||||
|
const [geminiModel, setGeminiModelState] = useState<string>(() => initialModel('gemini', GEMINI_MODELS.DEFAULT));
|
||||||
|
|
||||||
// Live model lists — fall back to static constants until API responds
|
// Live model lists — fall back to static constants until API responds
|
||||||
const [claudeModelOptions, setClaudeModelOptions] = useState<ModelOption[]>(CLAUDE_MODELS.OPTIONS);
|
const [claudeModelOptions, setClaudeModelOptions] = useState<ModelOption[]>(CLAUDE_MODELS.OPTIONS);
|
||||||
|
|
@ -45,7 +54,43 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
const [geminiModelOptions] = useState<ModelOption[]>(GEMINI_MODELS.OPTIONS);
|
const [geminiModelOptions] = useState<ModelOption[]>(GEMINI_MODELS.OPTIONS);
|
||||||
const [cursorModelOptions] = useState<ModelOption[]>(CURSOR_MODELS.OPTIONS);
|
const [cursorModelOptions] = useState<ModelOption[]>(CURSOR_MODELS.OPTIONS);
|
||||||
|
|
||||||
// Fetch live model list and validate the saved claude model
|
// Persisted setters: write BOTH the per-session pin (if a session is active)
|
||||||
|
// and the global last-used default, then update state.
|
||||||
|
const makePersistedSetter = useCallback(
|
||||||
|
(p: string, setState: (v: string) => void) => (value: string) => {
|
||||||
|
setState(value);
|
||||||
|
localStorage.setItem(globalModelKey(p), value);
|
||||||
|
if (selectedSession?.id) {
|
||||||
|
localStorage.setItem(sessionModelKey(p, selectedSession.id), value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedSession?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCursorModel = useCallback(makePersistedSetter('cursor', setCursorModelState), [makePersistedSetter]);
|
||||||
|
const setClaudeModel = useCallback(makePersistedSetter('claude', setClaudeModelState), [makePersistedSetter]);
|
||||||
|
const setCodexModel = useCallback(makePersistedSetter('codex', setCodexModelState), [makePersistedSetter]);
|
||||||
|
const setGeminiModel = useCallback(makePersistedSetter('gemini', setGeminiModelState), [makePersistedSetter]);
|
||||||
|
|
||||||
|
// When the selected session changes, load each provider's pinned model for
|
||||||
|
// that session (falling back to the global default). This is what stops a
|
||||||
|
// model picked in session A from leaking into session B.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSession?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = (p: string, setState: (v: string) => void, fallback: string) => {
|
||||||
|
const pinned = localStorage.getItem(sessionModelKey(p, selectedSession.id));
|
||||||
|
const next = pinned || localStorage.getItem(globalModelKey(p)) || fallback;
|
||||||
|
setState(next);
|
||||||
|
};
|
||||||
|
load('cursor', setCursorModelState, CURSOR_MODELS.DEFAULT);
|
||||||
|
load('claude', setClaudeModelState, CLAUDE_MODELS.DEFAULT);
|
||||||
|
load('codex', setCodexModelState, CODEX_MODELS.DEFAULT);
|
||||||
|
load('gemini', setGeminiModelState, GEMINI_MODELS.DEFAULT);
|
||||||
|
}, [selectedSession?.id]);
|
||||||
|
|
||||||
|
// Fetch live Claude model list and validate the current claude model
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authenticatedFetch('/api/models')
|
authenticatedFetch('/api/models')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
@ -57,18 +102,21 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
const options: ModelOption[] = data.claude;
|
const options: ModelOption[] = data.claude;
|
||||||
setClaudeModelOptions(options);
|
setClaudeModelOptions(options);
|
||||||
|
|
||||||
// Validate saved model — if it's no longer in the list, reset to default
|
setClaudeModelState((current) => {
|
||||||
setClaudeModel((current) => {
|
|
||||||
const valid = options.some((o) => o.value === current);
|
const valid = options.some((o) => o.value === current);
|
||||||
if (valid) return current;
|
if (valid) return current;
|
||||||
const fallback = options[0]?.value ?? CLAUDE_MODELS.DEFAULT;
|
const fallback = options[0]?.value ?? CLAUDE_MODELS.DEFAULT;
|
||||||
localStorage.setItem('claude-model', fallback);
|
localStorage.setItem(globalModelKey('claude'), fallback);
|
||||||
|
if (selectedSession?.id) {
|
||||||
|
localStorage.setItem(sessionModelKey('claude', selectedSession.id), fallback);
|
||||||
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Static fallback already in place — nothing to do
|
// Static fallback already in place
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lastProviderRef = useRef(provider);
|
const lastProviderRef = useRef(provider);
|
||||||
|
|
@ -119,8 +167,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelId = data.config.model.modelId as string;
|
const modelId = data.config.model.modelId as string;
|
||||||
if (!localStorage.getItem('cursor-model')) {
|
if (!localStorage.getItem(globalModelKey('cursor'))) {
|
||||||
setCursorModel(modelId);
|
setCursorModelState(modelId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@ export default function ModelSelectorBar({
|
||||||
gemini: setGeminiModel,
|
gemini: setGeminiModel,
|
||||||
};
|
};
|
||||||
setters[provider]?.(modelValue);
|
setters[provider]?.(modelValue);
|
||||||
localStorage.setItem(`${provider}-model`, modelValue);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -174,16 +174,12 @@ export default function ProviderSelectionEmptyState({
|
||||||
(providerId: LLMProvider, modelValue: string) => {
|
(providerId: LLMProvider, modelValue: string) => {
|
||||||
if (providerId === "claude") {
|
if (providerId === "claude") {
|
||||||
setClaudeModel(modelValue);
|
setClaudeModel(modelValue);
|
||||||
localStorage.setItem("claude-model", modelValue);
|
|
||||||
} else if (providerId === "codex") {
|
} else if (providerId === "codex") {
|
||||||
setCodexModel(modelValue);
|
setCodexModel(modelValue);
|
||||||
localStorage.setItem("codex-model", modelValue);
|
|
||||||
} else if (providerId === "gemini") {
|
} else if (providerId === "gemini") {
|
||||||
setGeminiModel(modelValue);
|
setGeminiModel(modelValue);
|
||||||
localStorage.setItem("gemini-model", modelValue);
|
|
||||||
} else {
|
} else {
|
||||||
setCursorModel(modelValue);
|
setCursorModel(modelValue);
|
||||||
localStorage.setItem("cursor-model", modelValue);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ export type SidebarProps = {
|
||||||
settingsInitialTab: string;
|
settingsInitialTab: string;
|
||||||
onCloseSettings: () => void;
|
onCloseSettings: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
|
processingStartTimes?: Map<string, number>;
|
||||||
|
needsInputSessions?: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionViewModel = {
|
export type SessionViewModel = {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ function Sidebar({
|
||||||
settingsInitialTab,
|
settingsInitialTab,
|
||||||
onCloseSettings,
|
onCloseSettings,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation(['sidebar', 'common']);
|
const { t } = useTranslation(['sidebar', 'common']);
|
||||||
const { isPWA } = useDeviceSettings({ trackMobile: false });
|
const { isPWA } = useDeviceSettings({ trackMobile: false });
|
||||||
|
|
@ -152,6 +155,9 @@ function Sidebar({
|
||||||
editingName,
|
editingName,
|
||||||
initialSessionsLoaded,
|
initialSessionsLoaded,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
deletingProjects,
|
deletingProjects,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ type SidebarProjectItemProps = {
|
||||||
initialSessionsLoaded: boolean;
|
initialSessionsLoaded: boolean;
|
||||||
isLoadingMoreSessions: boolean;
|
isLoadingMoreSessions: boolean;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
|
processingStartTimes?: Map<string, number>;
|
||||||
|
needsInputSessions?: Set<string>;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
|
|
@ -69,6 +72,9 @@ export default function SidebarProjectItem({
|
||||||
initialSessionsLoaded,
|
initialSessionsLoaded,
|
||||||
isLoadingMoreSessions,
|
isLoadingMoreSessions,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
|
|
@ -412,6 +418,9 @@ export default function SidebarProjectItem({
|
||||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
processingSessions={processingSessions}
|
||||||
|
processingStartTimes={processingStartTimes}
|
||||||
|
needsInputSessions={needsInputSessions}
|
||||||
editingSession={editingSession}
|
editingSession={editingSession}
|
||||||
editingSessionName={editingSessionName}
|
editingSessionName={editingSessionName}
|
||||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export type SidebarProjectListProps = {
|
||||||
editingName: string;
|
editingName: string;
|
||||||
initialSessionsLoaded: Set<string>;
|
initialSessionsLoaded: Set<string>;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
|
processingStartTimes?: Map<string, number>;
|
||||||
|
needsInputSessions?: Set<string>;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
deletingProjects: Set<string>;
|
deletingProjects: Set<string>;
|
||||||
|
|
@ -63,6 +66,9 @@ export default function SidebarProjectList({
|
||||||
editingName,
|
editingName,
|
||||||
initialSessionsLoaded,
|
initialSessionsLoaded,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
deletingProjects,
|
deletingProjects,
|
||||||
|
|
@ -131,6 +137,9 @@ export default function SidebarProjectList({
|
||||||
initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
|
initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
|
||||||
isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)}
|
isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
processingSessions={processingSessions}
|
||||||
|
processingStartTimes={processingStartTimes}
|
||||||
|
needsInputSessions={needsInputSessions}
|
||||||
editingSession={editingSession}
|
editingSession={editingSession}
|
||||||
editingSessionName={editingSessionName}
|
editingSessionName={editingSessionName}
|
||||||
tasksEnabled={tasksEnabled}
|
tasksEnabled={tasksEnabled}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ type SidebarProjectSessionsProps = {
|
||||||
hasMoreSessions: boolean;
|
hasMoreSessions: boolean;
|
||||||
isLoadingMoreSessions: boolean;
|
isLoadingMoreSessions: boolean;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
|
processingStartTimes?: Map<string, number>;
|
||||||
|
needsInputSessions?: Set<string>;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
onEditingSessionNameChange: (value: string) => void;
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
|
@ -62,6 +65,9 @@ export default function SidebarProjectSessions({
|
||||||
hasMoreSessions,
|
hasMoreSessions,
|
||||||
isLoadingMoreSessions,
|
isLoadingMoreSessions,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
onEditingSessionNameChange,
|
onEditingSessionNameChange,
|
||||||
|
|
@ -121,6 +127,9 @@ export default function SidebarProjectSessions({
|
||||||
session={session}
|
session={session}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
processingSessions={processingSessions}
|
||||||
|
processingStartTimes={processingStartTimes}
|
||||||
|
needsInputSessions={needsInputSessions}
|
||||||
editingSession={editingSession}
|
editingSession={editingSession}
|
||||||
editingSessionName={editingSessionName}
|
editingSessionName={editingSessionName}
|
||||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ type SidebarSessionItemProps = {
|
||||||
session: SessionWithProvider;
|
session: SessionWithProvider;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentTime: Date;
|
currentTime: Date;
|
||||||
|
processingSessions?: Set<string>;
|
||||||
|
processingStartTimes?: Map<string, number>;
|
||||||
|
needsInputSessions?: Set<string>;
|
||||||
editingSession: string | null;
|
editingSession: string | null;
|
||||||
editingSessionName: string;
|
editingSessionName: string;
|
||||||
onEditingSessionNameChange: (value: string) => void;
|
onEditingSessionNameChange: (value: string) => void;
|
||||||
|
|
@ -58,11 +61,25 @@ const formatCompactSessionAge = (dateString: string, currentTime: Date): string
|
||||||
return `${diffInDays}d`;
|
return `${diffInDays}d`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Live elapsed for a running session: Xs, then Mm Ss. */
|
||||||
|
const formatElapsed = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
const m = Math.floor(totalSeconds / 60);
|
||||||
|
const s = totalSeconds % 60;
|
||||||
|
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||||
|
};
|
||||||
|
|
||||||
export default function SidebarSessionItem({
|
export default function SidebarSessionItem({
|
||||||
project,
|
project,
|
||||||
session,
|
session,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
editingSession,
|
editingSession,
|
||||||
editingSessionName,
|
editingSessionName,
|
||||||
onEditingSessionNameChange,
|
onEditingSessionNameChange,
|
||||||
|
|
@ -76,6 +93,16 @@ export default function SidebarSessionItem({
|
||||||
}: SidebarSessionItemProps) {
|
}: SidebarSessionItemProps) {
|
||||||
const sessionView = createSessionViewModel(session, currentTime, t);
|
const sessionView = createSessionViewModel(session, currentTime, t);
|
||||||
const isSelected = selectedSession?.id === session.id;
|
const isSelected = selectedSession?.id === session.id;
|
||||||
|
const isNeedsInput = needsInputSessions?.has(session.id) ?? false;
|
||||||
|
const isProcessing = (processingSessions?.has(session.id) ?? false) && !isNeedsInput;
|
||||||
|
const isRecentlyActive = sessionView.isActive && !isProcessing && !isNeedsInput;
|
||||||
|
const processingStart = processingStartTimes?.get(session.id);
|
||||||
|
const elapsedLabel =
|
||||||
|
(isProcessing || isNeedsInput) && processingStart
|
||||||
|
? formatElapsed(currentTime.getTime() - processingStart)
|
||||||
|
: '';
|
||||||
|
const activityLabel = isNeedsInput ? 'Needs input' : isProcessing ? elapsedLabel : '';
|
||||||
|
const activityClass = isNeedsInput ? 'text-rose-400' : 'text-amber-400';
|
||||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||||
|
|
||||||
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
||||||
|
|
@ -95,20 +122,47 @@ export default function SidebarSessionItem({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
{sessionView.isActive && (
|
{isNeedsInput ? (
|
||||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
<div
|
||||||
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
|
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
|
||||||
|
title={t('sessions.needsInput', { defaultValue: 'Needs your input' })}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-rose-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-rose-500" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : isProcessing ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
|
||||||
|
title={t('sessions.agentRunning', { defaultValue: 'Agent running' })}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : isRecentlyActive ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
|
||||||
|
title={t('sessions.recentlyActive', { defaultValue: 'Recently active' })}
|
||||||
|
>
|
||||||
|
<span className="block h-1.5 w-1.5 rounded-full bg-sky-400/70" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
||||||
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
||||||
!isSelected && sessionView.isActive
|
!isSelected && isNeedsInput
|
||||||
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
? 'border-rose-500/40 bg-rose-50/10 dark:bg-rose-900/10'
|
||||||
: 'border-border/30',
|
: !isSelected && isProcessing
|
||||||
|
? 'border-amber-500/40 bg-amber-50/10 dark:bg-amber-900/10'
|
||||||
|
: !isSelected && isRecentlyActive
|
||||||
|
? 'border-sky-500/20'
|
||||||
|
: 'border-border/30',
|
||||||
)}
|
)}
|
||||||
onClick={selectMobileSession}
|
onClick={selectMobileSession}
|
||||||
>
|
>
|
||||||
|
|
@ -125,9 +179,11 @@ export default function SidebarSessionItem({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
{compactSessionAge && (
|
{activityLabel ? (
|
||||||
|
<span className={`ml-auto flex-shrink-0 text-[11px] font-medium ${activityClass}`}>{activityLabel}</span>
|
||||||
|
) : compactSessionAge ? (
|
||||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center">
|
<div className="mt-0.5 flex items-center">
|
||||||
{sessionView.messageCount > 0 && (
|
{sessionView.messageCount > 0 && (
|
||||||
|
|
@ -167,11 +223,13 @@ export default function SidebarSessionItem({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||||
{compactSessionAge && (
|
{activityLabel ? (
|
||||||
|
<span className={`ml-auto flex-shrink-0 text-[11px] font-medium ${activityClass}`}>{activityLabel}</span>
|
||||||
|
) : compactSessionAge ? (
|
||||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||||
{compactSessionAge}
|
{compactSessionAge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center">
|
<div className="mt-0.5 flex items-center">
|
||||||
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ type UseProjectsStateArgs = {
|
||||||
latestMessage: AppSocketMessage | null;
|
latestMessage: AppSocketMessage | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
activeSessions: Set<string>;
|
activeSessions: Set<string>;
|
||||||
|
processingSessions: Set<string>;
|
||||||
|
processingStartTimes: Map<string, number>;
|
||||||
|
needsInputSessions: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchProjectsOptions = {
|
type FetchProjectsOptions = {
|
||||||
|
|
@ -241,6 +244,9 @@ export function useProjectsState({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
isMobile,
|
isMobile,
|
||||||
activeSessions,
|
activeSessions,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
}: UseProjectsStateArgs) {
|
}: UseProjectsStateArgs) {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
|
|
@ -830,6 +836,9 @@ export function useProjectsState({
|
||||||
settingsInitialTab,
|
settingsInitialTab,
|
||||||
onCloseSettings: () => setShowSettings(false),
|
onCloseSettings: () => setShowSettings(false),
|
||||||
isMobile,
|
isMobile,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
|
|
@ -847,6 +856,9 @@ export function useProjectsState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
showSettings,
|
showSettings,
|
||||||
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { useCallback, useState } from 'react';
|
||||||
export function useSessionProtection() {
|
export function useSessionProtection() {
|
||||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||||
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
||||||
|
// sessionId -> epoch ms when processing began (for the live elapsed timer)
|
||||||
|
const [processingStartTimes, setProcessingStartTimes] = useState<Map<string, number>>(new Map());
|
||||||
|
// sessions waiting on the user (permission / AskUserQuestion pending)
|
||||||
|
const [needsInputSessions, setNeedsInputSessions] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
|
|
@ -30,6 +34,16 @@ export function useSessionProtection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
||||||
|
setProcessingStartTimes((prev) => {
|
||||||
|
// Preserve an existing start time so the elapsed timer doesn't reset on
|
||||||
|
// repeated marks for the same run.
|
||||||
|
if (prev.has(sessionId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(sessionId, Date.now());
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
||||||
|
|
@ -42,14 +56,49 @@ export function useSessionProtection() {
|
||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setProcessingStartTimes((prev) => {
|
||||||
|
if (!prev.has(sessionId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSessionAsNeedsInput = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNeedsInputSessions((prev) => new Set([...prev, sessionId]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSessionNeedsInput = useCallback((sessionId?: string | null) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNeedsInputSessions((prev) => {
|
||||||
|
if (!prev.has(sessionId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSessions,
|
activeSessions,
|
||||||
processingSessions,
|
processingSessions,
|
||||||
|
processingStartTimes,
|
||||||
|
needsInputSessions,
|
||||||
markSessionAsActive,
|
markSessionAsActive,
|
||||||
markSessionAsInactive,
|
markSessionAsInactive,
|
||||||
markSessionAsProcessing,
|
markSessionAsProcessing,
|
||||||
markSessionAsNotProcessing,
|
markSessionAsNotProcessing,
|
||||||
|
markSessionAsNeedsInput,
|
||||||
|
clearSessionNeedsInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue