Add plan mode and plan view with per-session agent context (#210)

* feat: add plan/build mode switching and Plan view tab

Add PlanView view and integrate plan tab into main layout
Introduce plan_enter and plan_exit tools with icons and status strings
Persist per-session agent selections in context to reduce UI flicker during mode switches

* feat: enchanced plan discovery checks in header and plan view

* fix(ui): align PlanView and Header with sessionDirectory logic

Remove dependency on context store in header and compute sessionDirectory from session or currentDirectory
Compute display and repo paths using sessionDirectory for PlanView and update path resolution
Poll plan content every 3 seconds to reflect changes without heavy retries

* feat(ui): improve header tab switching and add copy buttons for plan

Switch header tab navigation to central UI store and auto-switch when plan is unavailable
Show checkmark icon after copying file contents or path with auto-hide timeout
Extend plan view path resolution to prefer session directory when present
This commit is contained in:
Bohdan Triapitsyn
2026-01-25 15:52:02 +02:00
committed by GitHub
parent 75f781f940
commit f4da45a8ad
22 changed files with 1353 additions and 112 deletions
+43 -1
View File
@@ -101,7 +101,16 @@ export const useContextStore = create<ContextStore>()(
set((state) => {
const newSelections = new Map(state.sessionAgentSelections);
newSelections.set(sessionId, agentName);
return { sessionAgentSelections: newSelections };
// Keep a "current" agent context for components that only know sessionId.
// This is also used by external-session inference logic.
const nextAgentContext = new Map(state.currentAgentContext);
nextAgentContext.set(sessionId, agentName);
return {
sessionAgentSelections: newSelections,
currentAgentContext: nextAgentContext,
};
});
},
@@ -212,6 +221,13 @@ export const useContextStore = create<ContextStore>()(
}
}
if ("agent" in messageInfo && messageInfo.agent && typeof messageInfo.agent === "string") {
const agent = agents.find((a) => a.name === messageInfo.agent);
if (agent) {
return messageInfo.agent;
}
}
if (messageInfo.providerID && messageInfo.modelID) {
const matchingAgent = agents.find((agent) => agent.model?.providerID === messageInfo.providerID && agent.model?.modelID === messageInfo.modelID);
if (matchingAgent) {
@@ -276,6 +292,32 @@ export const useContextStore = create<ContextStore>()(
// User messages have variant and model info in different structure
if (infoAny.role === "user") {
const agentName = typeof infoAny.mode === 'string' && infoAny.mode.trim().length > 0
? infoAny.mode
: (typeof infoAny.agent === 'string' && infoAny.agent.trim().length > 0 ? infoAny.agent : undefined);
const userProvider = typeof infoAny.model?.providerID === 'string' && infoAny.model.providerID.trim().length > 0
? infoAny.model.providerID
: (typeof infoAny.providerID === 'string' && infoAny.providerID.trim().length > 0 ? infoAny.providerID : undefined);
const userModel = typeof infoAny.model?.modelID === 'string' && infoAny.model.modelID.trim().length > 0
? infoAny.model.modelID
: (typeof infoAny.modelID === 'string' && infoAny.modelID.trim().length > 0 ? infoAny.modelID : undefined);
if (agentName && userProvider && userModel && agents.find((a) => a.name === agentName)) {
const choice = {
providerId: userProvider,
modelId: userModel,
timestamp: info.time.created,
};
const existing = agentLastChoices.get(agentName);
if (!existing || choice.timestamp > existing.timestamp) {
agentLastChoices.set(agentName, choice);
}
if (typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0) {
saveAgentModelVariantForSession(sessionId, agentName, userProvider, userModel, infoAny.variant);
}
}
// User message: variant is top-level, model is nested in model.providerID/modelID
pendingVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0
? infoAny.variant
+16 -4
View File
@@ -1106,8 +1106,14 @@ export const useMessageStore = create<MessageStore>()(
const existingPartIndex = existingMessage.parts.findIndex((p) => p.id === part.id);
if ((part as any).synthetic === true) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part');
return state;
const incomingText = extractTextFromPart(part).trim();
const shouldKeep =
incomingText.startsWith('User has requested to enter plan mode') ||
incomingText.startsWith('The plan at ');
if (!shouldKeep) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part');
return state;
}
}
const normalizedPart = normalizeStreamingPart(
@@ -1181,8 +1187,14 @@ export const useMessageStore = create<MessageStore>()(
if (actualRole === 'user') {
if ((part as any).synthetic === true) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part');
return state;
const incomingText = extractTextFromPart(part).trim();
const shouldKeep =
incomingText.startsWith('User has requested to enter plan mode') ||
incomingText.startsWith('The plan at ');
if (!shouldKeep) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part');
return state;
}
}
const normalizedPart = normalizeStreamingPart(part);
+1 -1
View File
@@ -4,7 +4,7 @@ import type { SidebarSection } from '@/constants/sidebar';
import { getSafeStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal' | 'files';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type MainTabGuard = (nextTab: MainTab) => boolean;
export type EventStreamStatus =