From ecd73936468587b169dbec1dfd67483211b2bb4d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 27 Mar 2026 21:48:06 +0000 Subject: [PATCH 01/49] feat: persist task lane across submits and session reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task lane questions/actions are now saved to a pending_task_lane JSONB column on ai_sessions, restoring them on session switch or page reload. Partial submit no longer force-clears the lane — the AI response controls what stays. Also removes redundant "New Session" button from the sidebar (dashboard already provides this). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/fc01_add_pending_task_lane.py | 30 +++++++++++++++++++ backend/app/models/ai_session.py | 4 +++ backend/app/schemas/ai_session.py | 1 + backend/app/services/unified_chat_service.py | 18 +++++++++++ frontend/src/components/layout/Sidebar.tsx | 25 +--------------- frontend/src/hooks/useFlowPilotSession.ts | 1 + frontend/src/pages/AssistantChatPage.tsx | 17 +++++++---- frontend/src/types/ai-session.ts | 1 + 8 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 backend/alembic/versions/fc01_add_pending_task_lane.py diff --git a/backend/alembic/versions/fc01_add_pending_task_lane.py b/backend/alembic/versions/fc01_add_pending_task_lane.py new file mode 100644 index 00000000..4a579c38 --- /dev/null +++ b/backend/alembic/versions/fc01_add_pending_task_lane.py @@ -0,0 +1,30 @@ +"""add pending_task_lane to ai_sessions + +Revision ID: fc01a1b2c3d4 +Revises: fb1481317ff6 +Create Date: 2026-03-27 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "fc01a1b2c3d4" +down_revision = "fb1481317ff6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_sessions", + sa.Column( + "pending_task_lane", + JSONB, + nullable=True, + comment="Current task lane state: {questions: [...], actions: [...]}", + ), + ) + + +def downgrade() -> None: + op.drop_column("ai_sessions", "pending_task_lane") diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index 0c10d9db..8bf1684a 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -209,6 +209,10 @@ class AISession(Base): JSONB, nullable=False, default=list, comment="Full LLM message history for context continuity", ) + pending_task_lane: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True, + comment="Current task lane state: {questions: [...], actions: [...]}", + ) # ── Branching ── is_branching: Mapped[bool] = mapped_column( diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 4037f38a..0039653e 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -228,6 +228,7 @@ class AISessionDetail(AISessionSummary): ticket_data: dict[str, Any] | None = None steps: list[AISessionStepResponse] = [] conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here + pending_task_lane: dict[str, Any] | None = None is_branching: bool = False active_branch_id: str | None = None diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py index 494eb01f..44236e66 100644 --- a/backend/app/services/unified_chat_service.py +++ b/backend/app/services/unified_chat_service.py @@ -286,6 +286,15 @@ async def send_chat_message( except Exception: logger.exception("Failed to create fork within branch for session %s", session.id) + # Persist task lane state on session + if branch_questions_data or branch_actions_data: + session.pending_task_lane = { + "questions": branch_questions_data or [], + "actions": branch_actions_data or [], + } + else: + session.pending_task_lane = None + suggested_flows = extract_suggested_flows( await rag_search(query=message, account_id=account_id, db=db, limit=8) ) @@ -393,6 +402,15 @@ async def send_chat_message( logger.exception("Failed to create fork for session %s", session_id) # Fork failed but chat message still sent — don't break the response + # Persist task lane state on session + if questions_data or actions_data: + session.pending_task_lane = { + "questions": questions_data or [], + "actions": actions_data or [], + } + else: + session.pending_task_lane = None + suggested_flows = extract_suggested_flows(rag_results) return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 3c3b825a..2b2fa664 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -5,7 +5,7 @@ import { LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, ListChecks, Download, BarChart3, Settings, Pin, PinOff, - Plus, History, FileText, + History, FileText, } from 'lucide-react' import { cn } from '@/lib/utils' import { useUserPreferencesStore } from '@/store/userPreferencesStore' @@ -363,17 +363,6 @@ export function Sidebar() { style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }} onWheel={handleWheel} > - {/* New Session button */} -
- - - New Session - -
- {/* Pinned sidebar content */}
{sections.map((section, si) => ( @@ -420,18 +409,6 @@ export function Sidebar() { style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)', width: '72px' }} onWheel={handleWheel} > - {/* New Session button */} -
- - - New - -
- {/* Nav items */}
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))} diff --git a/frontend/src/hooks/useFlowPilotSession.ts b/frontend/src/hooks/useFlowPilotSession.ts index 8b0f661c..6034cbb0 100644 --- a/frontend/src/hooks/useFlowPilotSession.ts +++ b/frontend/src/hooks/useFlowPilotSession.ts @@ -92,6 +92,7 @@ export function useFlowPilotSession(): UseFlowPilotSession { ticket_data: null, steps: [firstStep], conversation_messages: [], + pending_task_lane: null, is_branching: false, active_branch_id: null, }) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index fad0b11d..db060d0d 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -160,7 +160,7 @@ export default function AssistantChatPage() { const selectChat = useCallback(async (chatId: string) => { setActiveChatId(chatId) - // Clear TaskLane when switching chats + // Clear TaskLane when switching chats — will restore from backend if available setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) @@ -172,6 +172,16 @@ export default function AssistantChatPage() { content: m.content, })) ) + // Restore task lane from persisted state + if (detail.pending_task_lane) { + const q = detail.pending_task_lane.questions || [] + const a = detail.pending_task_lane.actions || [] + if (q.length > 0 || a.length > 0) { + setActiveQuestions(q) + setActiveActions(a) + setShowTaskLane(true) + } + } } catch { setMessages([]) } @@ -288,11 +298,6 @@ export default function AssistantChatPage() { } const userMessage = parts.join('\n\n') - // Close the task lane - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) - setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setLoading(true) diff --git a/frontend/src/types/ai-session.ts b/frontend/src/types/ai-session.ts index 446f3149..e1abab7f 100644 --- a/frontend/src/types/ai-session.ts +++ b/frontend/src/types/ai-session.ts @@ -195,6 +195,7 @@ export interface AISessionDetail extends AISessionSummary { ticket_data: Record | null steps: AISessionStepResponse[] conversation_messages: Array<{ role: string; content: string }> + pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[] } | null is_branching: boolean active_branch_id: string | null } From a285b6cdc21864dda25f6b5740ab15038e1ec741 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 02:12:34 +0000 Subject: [PATCH 02/49] fix: rebase migration onto correct alembic head The pending_task_lane migration was branching from fb1481317ff6 which already had a child (4f4137ce79e5). Fixes multiple-heads error. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/alembic/versions/fc01_add_pending_task_lane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/fc01_add_pending_task_lane.py b/backend/alembic/versions/fc01_add_pending_task_lane.py index 4a579c38..ad3996da 100644 --- a/backend/alembic/versions/fc01_add_pending_task_lane.py +++ b/backend/alembic/versions/fc01_add_pending_task_lane.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB revision = "fc01a1b2c3d4" -down_revision = "fb1481317ff6" +down_revision = "4f4137ce79e5" branch_labels = None depends_on = None From 3bb3712d741069c27bc6a89dc56944b612762a60 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 02:17:51 +0000 Subject: [PATCH 03/49] chore: trigger fresh Railway build (clear Docker cache) From d07e622aef35e1b7b5e6b9d5859f940bec2160f2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 02:20:18 +0000 Subject: [PATCH 04/49] fix: rebase migration onto actual head (067) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/alembic/versions/fc01_add_pending_task_lane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/fc01_add_pending_task_lane.py b/backend/alembic/versions/fc01_add_pending_task_lane.py index ad3996da..632de9b4 100644 --- a/backend/alembic/versions/fc01_add_pending_task_lane.py +++ b/backend/alembic/versions/fc01_add_pending_task_lane.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB revision = "fc01a1b2c3d4" -down_revision = "4f4137ce79e5" +down_revision = "067" branch_labels = None depends_on = None From e8dfc84717b2a3d0b38cb3fef23fd52316b7a167 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 02:23:56 +0000 Subject: [PATCH 05/49] fix: rename migration to sequential 068, document convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed fc01_add_pending_task_lane → 068_add_pending_task_lane with revision ID "068" and down_revision "067". Added migration naming convention to CLAUDE.md to prevent future hex-hash migrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 6 +++++- ...dd_pending_task_lane.py => 068_add_pending_task_lane.py} | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) rename backend/alembic/versions/{fc01_add_pending_task_lane.py => 068_add_pending_task_lane.py} (88%) diff --git a/CLAUDE.md b/CLAUDE.md index 21a34431..d87335e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,10 @@ cd frontend && npm run build # Database migrations cd backend && alembic upgrade head alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number +# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069). +# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1 +# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py). +# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations. # Access PostgreSQL docker exec -it patherly_postgres psql -U postgres -d patherly @@ -415,7 +419,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi - **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client - **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx` - **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children. -- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head` +- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head` - **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts` --- diff --git a/backend/alembic/versions/fc01_add_pending_task_lane.py b/backend/alembic/versions/068_add_pending_task_lane.py similarity index 88% rename from backend/alembic/versions/fc01_add_pending_task_lane.py rename to backend/alembic/versions/068_add_pending_task_lane.py index 632de9b4..a8da1977 100644 --- a/backend/alembic/versions/fc01_add_pending_task_lane.py +++ b/backend/alembic/versions/068_add_pending_task_lane.py @@ -1,14 +1,14 @@ """add pending_task_lane to ai_sessions -Revision ID: fc01a1b2c3d4 -Revises: fb1481317ff6 +Revision ID: 068 +Revises: 067 Create Date: 2026-03-27 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB -revision = "fc01a1b2c3d4" +revision = "068" down_revision = "067" branch_labels = None depends_on = None From 6268aa3520aae2c1f582650c6893aab15e9f68f4 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 03:05:58 +0000 Subject: [PATCH 06/49] fix: task lane no longer self-hides on submit TaskLane had `if (submitted) return null` which immediately hid the component after submit, before the AI could respond. Now the parent controls visibility: the lane stays visible during the AI call, then either updates with new tasks or clears when the AI sends none. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/assistant/TaskLane.tsx | 8 +++----- frontend/src/pages/AssistantChatPage.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 80314e3b..e9972ca6 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -50,7 +50,6 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas })), ]) const [submitting, setSubmitting] = useState(false) - const [submitted, setSubmitted] = useState(false) const [showRunAll, setShowRunAll] = useState(false) const [showPreview, setShowPreview] = useState(false) @@ -112,7 +111,6 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', })), ]) - setSubmitted(false) }, [questions, actions]) const updateTask = (idx: number, updates: Partial) => { @@ -164,12 +162,12 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas const handleSubmit = () => { setSubmitting(true) onSubmit(tasks) - setSubmitted(true) + // Don't self-hide — parent controls visibility via showTaskLane. + // The AI response will either send updated tasks (replacing these) + // or send none (parent hides the lane). setSubmitting(false) } - if (submitted) return null - return (
0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) + } else { + // AI sent no new tasks — clear the lane + setShowTaskLane(false) + setActiveQuestions([]) + setActiveActions([]) } } catch { setMessages(prev => [ From e9f96474e0814a844ea6d4805f129cc63601538b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 04:21:42 +0000 Subject: [PATCH 07/49] feat: persist task lane state across reloads and session switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskLane now saves user's in-progress answers (typed text, checked items) to sessionStorage keyed by session ID. On reload or session switch, the full task lane state restores — including partial work. - TaskLane: saves tasks array to sessionStorage on every change, restores from sessionStorage on mount - AssistantChatPage: saves task lane metadata (visibility, questions, actions, chatId) to sessionStorage, restores on mount - Closing the task lane clears its sessionStorage entry Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/assistant/TaskLane.tsx | 52 +++++++++++++++---- frontend/src/pages/AssistantChatPage.tsx | 44 ++++++++++++++-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index e9972ca6..ddd60d79 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -33,22 +33,51 @@ type TaskResponse = QuestionResponse | ActionResponse interface TaskLaneProps { questions: QuestionItem[] actions: ActionItem[] + sessionId?: string | null onSubmit: (responses: TaskResponse[]) => void onClose: () => void loading?: boolean } +// ── Storage helpers ── + +const TASK_LANE_STORAGE_KEY = 'rf-tasklane-state' + +function saveTaskState(sessionId: string, tasks: TaskResponse[]) { + try { + sessionStorage.setItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`, JSON.stringify(tasks)) + } catch { /* quota exceeded — ignore */ } +} + +function loadTaskState(sessionId: string): TaskResponse[] | null { + try { + const stored = sessionStorage.getItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) + return stored ? JSON.parse(stored) : null + } catch { return null } +} + +export function clearTaskState(sessionId: string) { + try { sessionStorage.removeItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) } catch { /* ignore */ } +} + // ── Component ── -export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) { - const [tasks, setTasks] = useState(() => [ - ...questions.map((q): QuestionResponse => ({ - type: 'question', text: q.text, context: q.context, state: 'pending', value: '', - })), - ...actions.map((a): ActionResponse => ({ - type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', - })), - ]) +export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) { + const [tasks, setTasks] = useState(() => { + // Try to restore saved state for this session (preserves user's in-progress answers) + if (sessionId) { + const saved = loadTaskState(sessionId) + if (saved && saved.length > 0) return saved + } + return [ + ...questions.map((q): QuestionResponse => ({ + type: 'question', text: q.text, context: q.context, state: 'pending', value: '', + })), + ...actions.map((a): ActionResponse => ({ + type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', + })), + ] + }) const [submitting, setSubmitting] = useState(false) const [showRunAll, setShowRunAll] = useState(false) const [showPreview, setShowPreview] = useState(false) @@ -100,6 +129,11 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas } }, [handleMouseMove, handleMouseUp]) + // Save task state to sessionStorage on every change + useEffect(() => { + if (sessionId) saveTaskState(sessionId, tasks) + }, [sessionId, tasks]) + // Reset when new tasks come in from AI response useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 61b665d0..18b7d218 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatMessage } from '@/components/assistant/ChatMessage' -import { TaskLane } from '@/components/assistant/TaskLane' +import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' @@ -42,9 +42,27 @@ export default function AssistantChatPage() { const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) - const [activeQuestions, setActiveQuestions] = useState([]) - const [activeActions, setActiveActions] = useState([]) - const [showTaskLane, setShowTaskLane] = useState(false) + const [activeQuestions, setActiveQuestions] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.questions || [] } + } catch { /* ignore */ } + return [] + }) + const [activeActions, setActiveActions] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.actions || [] } + } catch { /* ignore */ } + return [] + }) + const [showTaskLane, setShowTaskLane] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === (urlSessionId || null) } + } catch { /* ignore */ } + return false + }) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' ) @@ -137,6 +155,18 @@ export default function AssistantChatPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Persist task lane metadata to sessionStorage + useEffect(() => { + try { + sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ + show: showTaskLane, + chatId: activeChatId, + questions: activeQuestions, + actions: activeActions, + })) + } catch { /* ignore */ } + }, [showTaskLane, activeChatId, activeQuestions, activeActions]) + // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -737,8 +767,12 @@ export default function AssistantChatPage() { setShowTaskLane(false)} + onClose={() => { + setShowTaskLane(false) + if (activeChatId) clearTaskState(activeChatId) + }} loading={loading} /> )} From 1e78977e4fc7f7967bf0a115bcf9da2bfc25db93 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 05:39:09 +0000 Subject: [PATCH 08/49] feat: restore active chat session on reload Save activeChatId to sessionStorage so reloading /assistant restores the last conversation. Messages load from backend conversation_messages, task lane restores from sessionStorage (with partial answers intact). Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 18b7d218..3dcd4c27 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -31,7 +31,10 @@ export default function AssistantChatPage() { const navigate = useNavigate() const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>() const [chats, setChats] = useState([]) - const [activeChatId, setActiveChatId] = useState(urlSessionId || null) + const [activeChatId, setActiveChatId] = useState(() => { + if (urlSessionId) return urlSessionId + try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null } + }) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) @@ -77,6 +80,14 @@ export default function AssistantChatPage() { const dragCounterRef = useRef(0) const prefillHandledRef = useRef(false) + // Persist active chat ID to sessionStorage + useEffect(() => { + try { + if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId) + else sessionStorage.removeItem('rf-active-chat-id') + } catch { /* ignore */ } + }, [activeChatId]) + // Load chat list from ai_sessions useEffect(() => { loadChats() @@ -89,6 +100,13 @@ export default function AssistantChatPage() { } }, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps + // Restore session from sessionStorage on mount (when URL has no session ID) + useEffect(() => { + if (!urlSessionId && activeChatId) { + selectChat(activeChatId) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + // Handle prefill from command palette / dashboard handoff useEffect(() => { const state = location.state as { prefill?: string; uploadIds?: string[] } | null From 977e5a8ddb25a1ec5988c4cd6cc458a258950e71 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 13:03:06 +0000 Subject: [PATCH 09/49] fix: task lane restore checks activeChatId, not urlSessionId The showTaskLane initializer was comparing the saved chatId against urlSessionId (null on /assistant), so it never matched. Now compares against activeChatId which is already restored from sessionStorage. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 3dcd4c27..45ce031b 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -48,21 +48,21 @@ export default function AssistantChatPage() { const [activeQuestions, setActiveQuestions] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); return d.questions || [] } + if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] } } catch { /* ignore */ } return [] }) const [activeActions, setActiveActions] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); return d.actions || [] } + if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] } } catch { /* ignore */ } return [] }) const [showTaskLane, setShowTaskLane] = useState(() => { try { const saved = sessionStorage.getItem('rf-tasklane-meta') - if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === (urlSessionId || null) } + if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId } } catch { /* ignore */ } return false }) From 80af408f2d51a30d416cfc391659d08248c7af8b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 19:11:13 +0000 Subject: [PATCH 10/49] feat: persist task lane user responses to backend Add PUT /ai-sessions/{id}/task-lane endpoint that saves the full task lane state (AI questions/actions + user's in-progress responses) to the pending_task_lane JSONB column. TaskLane debounce-saves to the backend every 2s after changes. On session load, user responses are restored from the backend into sessionStorage so TaskLane picks them up on mount. Users can now close the browser, come back later, and find their task lane exactly where they left it. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/ai_sessions.py | 28 +++++++++++++++++++ backend/app/schemas/ai_session.py | 10 +++++++ frontend/src/api/aiSessions.ts | 8 ++++++ .../src/components/assistant/TaskLane.tsx | 19 +++++++++++-- frontend/src/pages/AssistantChatPage.tsx | 7 +++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 722f54b7..b5420787 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -48,6 +48,7 @@ from app.schemas.ai_session import ( ChatSessionCreateResponse, ChatMessageRequest, ChatMessageResponse, + SaveTaskLaneRequest, ) from app.services import flowpilot_engine from app.services import unified_chat_service @@ -497,6 +498,33 @@ async def pause_session( await db.commit() +# ── Save Task Lane ── + +@router.put("/{session_id}/task-lane", status_code=204) +@limiter.limit("30/minute") +async def save_task_lane( + request: Request, + session_id: UUID, + body: SaveTaskLaneRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Save the current task lane state including user's in-progress responses.""" + session = await db.get(AISession, session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your session") + + session.pending_task_lane = { + "questions": [q.model_dump() for q in body.questions], + "actions": [a.model_dump() for a in body.actions], + "responses": body.responses, + } + await db.commit() + + # ── Resume ── @router.post("/{session_id}/resume", status_code=204) diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 0039653e..23bfd652 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -287,6 +287,16 @@ class ChatMessageResponse(BaseModel): questions: list[QuestionItem] | None = None +class SaveTaskLaneRequest(BaseModel): + """Save the full task lane state (AI items + user responses).""" + questions: list[QuestionItem] = [] + actions: list[ActionItem] = [] + responses: list[dict[str, Any]] = Field( + default_factory=list, + description="User's in-progress task responses with state/value", + ) + + class AISessionSearchResult(BaseModel): """Lightweight session result for Command Palette / autocomplete.""" id: UUID diff --git a/frontend/src/api/aiSessions.ts b/frontend/src/api/aiSessions.ts index 39259c1b..08aae5bc 100644 --- a/frontend/src/api/aiSessions.ts +++ b/frontend/src/api/aiSessions.ts @@ -110,6 +110,14 @@ export const aiSessionsApi = { return response.data }, + async saveTaskLane(sessionId: string, data: { + questions: Array<{ text: string; context?: string }>; + actions: Array<{ label: string; command?: string | null; description?: string }>; + responses: Array>; + }): Promise { + await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data) + }, + async pauseSession(sessionId: string): Promise { await apiClient.post(`/ai-sessions/${sessionId}/pause`) }, diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index ddd60d79..791eba74 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -5,6 +5,7 @@ import { } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' +import { aiSessionsApi } from '@/api/aiSessions' import type { ActionItem, QuestionItem } from '@/types/ai-session' // ── Types ── @@ -129,10 +130,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa } }, [handleMouseMove, handleMouseUp]) - // Save task state to sessionStorage on every change + // Save task state to sessionStorage on every change + debounce to backend + const saveTimerRef = useRef | null>(null) useEffect(() => { - if (sessionId) saveTaskState(sessionId, tasks) - }, [sessionId, tasks]) + if (!sessionId) return + saveTaskState(sessionId, tasks) + // Debounce save to backend (2s after last change) + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { + aiSessionsApi.saveTaskLane(sessionId, { + questions: questions.map(q => ({ text: q.text, context: q.context })), + actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })), + responses: tasks as unknown as Array>, + }).catch(() => { /* silent — best-effort save */ }) + }, 2000) + return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } + }, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps // Reset when new tasks come in from AI response useEffect(() => { diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 45ce031b..3a95f680 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -228,6 +228,13 @@ export default function AssistantChatPage() { setActiveQuestions(q) setActiveActions(a) setShowTaskLane(true) + // Pre-load user's saved responses into sessionStorage so TaskLane restores them + const responses = (detail.pending_task_lane as Record).responses as unknown[] | undefined + if (responses && responses.length > 0) { + try { + sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) + } catch { /* ignore */ } + } } } } catch { From 4fa26149e617e3857d04570b8e32b0e20b8467ce Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 19:16:06 +0000 Subject: [PATCH 11/49] fix: add payload size limits to task lane save endpoint - Max 50 questions, 50 actions, 100 responses (Pydantic max_length) - Max 256KB total serialized payload (prevents DB bloat) - Existing guards: JWT auth, role check, ownership check, rate limit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/ai_sessions.py | 9 ++++++++- backend/app/schemas/ai_session.py | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index b5420787..4ffd7097 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -517,11 +517,18 @@ async def save_task_lane( if session.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not your session") - session.pending_task_lane = { + payload = { "questions": [q.model_dump() for q in body.questions], "actions": [a.model_dump() for a in body.actions], "responses": body.responses, } + + # Guard against oversized payloads (max 256KB serialized) + import json + if len(json.dumps(payload)) > 256 * 1024: + raise HTTPException(status_code=413, detail="Task lane payload too large") + + session.pending_task_lane = payload await db.commit() diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 23bfd652..fd37fde6 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -289,10 +289,11 @@ class ChatMessageResponse(BaseModel): class SaveTaskLaneRequest(BaseModel): """Save the full task lane state (AI items + user responses).""" - questions: list[QuestionItem] = [] - actions: list[ActionItem] = [] + questions: list[QuestionItem] = Field(default_factory=list, max_length=50) + actions: list[ActionItem] = Field(default_factory=list, max_length=50) responses: list[dict[str, Any]] = Field( default_factory=list, + max_length=100, description="User's in-progress task responses with state/value", ) From 96602a66769c04a7fc5e5fbe0baff684211d56cb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 21:44:04 +0000 Subject: [PATCH 12/49] fix: return pending_task_lane in session detail API response + debug logging _build_session_detail was omitting pending_task_lane, is_branching, and active_branch_id from the GET /ai-sessions/{id} response. The fields existed on the schema and model but were never passed in the manual constructor, so task lane state could never be restored on navigation. Also adds console logging to AssistantChatPage selectChat flow to diagnose message restoration, and fixes ScriptTemplateEditor stepper dismiss firing during programmatic script_body updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/ai_sessions.py | 3 +++ .../components/script-editor/ScriptTemplateEditor.tsx | 10 +++++++--- frontend/src/pages/AssistantChatPage.tsx | 7 ++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 4ffd7097..537737e6 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -117,6 +117,9 @@ def _build_session_detail(session: AISession) -> AISessionDetail: resolved_at=session.resolved_at, steps=step_responses, conversation_messages=session.conversation_messages or [], + pending_task_lane=session.pending_task_lane, + is_branching=getattr(session, 'is_branching', False), + active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None, ) diff --git a/frontend/src/components/script-editor/ScriptTemplateEditor.tsx b/frontend/src/components/script-editor/ScriptTemplateEditor.tsx index 0b3a8671..d0e3228e 100644 --- a/frontend/src/components/script-editor/ScriptTemplateEditor.tsx +++ b/frontend/src/components/script-editor/ScriptTemplateEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { ArrowLeft, Loader2, Save, Scan, Trash2 } from 'lucide-react' import { Input } from '@/components/ui/Input' import { Textarea } from '@/components/ui/Textarea' @@ -64,16 +64,19 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) { const [detectedCandidates, setDetectedCandidates] = useState([]) const [showStepper, setShowStepper] = useState(false) const [detectionSummary, setDetectionSummary] = useState(null) + const acceptingCandidateRef = useRef(false) const { canShareScriptTemplate } = usePermissions() - // Dismiss stepper if user edits the script body during detection + // Dismiss stepper if user manually edits the script body during detection + // (but NOT when handleAcceptCandidate programmatically updates script_body) const scriptBodyRef = form.script_body useEffect(() => { - if (showStepper) { + if (showStepper && !acceptingCandidateRef.current) { setShowStepper(false) setDetectedCandidates([]) } + acceptingCandidateRef.current = false // eslint-disable-next-line react-hooks/exhaustive-deps }, [scriptBodyRef]) @@ -263,6 +266,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) { sensitive: overrides.sensitive, } + acceptingCandidateRef.current = true setForm(f => ({ ...f, script_body: updatedScript, diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 3a95f680..30e20a2b 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -102,7 +102,9 @@ export default function AssistantChatPage() { // Restore session from sessionStorage on mount (when URL has no session ID) useEffect(() => { + console.log('[AssistantChat] Mount restore check — urlSessionId:', urlSessionId, 'activeChatId:', activeChatId) if (!urlSessionId && activeChatId) { + console.log('[AssistantChat] Calling selectChat to restore:', activeChatId) selectChat(activeChatId) } }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -207,6 +209,7 @@ export default function AssistantChatPage() { } const selectChat = useCallback(async (chatId: string) => { + console.log('[AssistantChat] selectChat called with:', chatId) setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available setShowTaskLane(false) @@ -214,6 +217,7 @@ export default function AssistantChatPage() { setActiveActions([]) try { const detail = await aiSessionsApi.getSession(chatId) + console.log('[AssistantChat] getSession response — messages:', detail.conversation_messages?.length, 'pending_task_lane:', !!detail.pending_task_lane) setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', @@ -237,7 +241,8 @@ export default function AssistantChatPage() { } } } - } catch { + } catch (err) { + console.error('[AssistantChat] Failed to load chat session:', err) setMessages([]) } }, []) From 0483b6f0f5516995745ee1694ee9dd38fd175047 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 22:18:17 +0000 Subject: [PATCH 13/49] fix: prevent Monaco onChange echo from dismissing parameter stepper ScriptBodyEditor's onChange fired when the value prop changed externally (from handleAcceptCandidate inserting placeholders), creating a feedback loop that reset acceptingCandidateRef before the second useEffect cycle. Guard onChange to only propagate when the value actually differs from the current prop. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/script-editor/ScriptBodyEditor.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/script-editor/ScriptBodyEditor.tsx b/frontend/src/components/script-editor/ScriptBodyEditor.tsx index c4c505be..03a651ba 100644 --- a/frontend/src/components/script-editor/ScriptBodyEditor.tsx +++ b/frontend/src/components/script-editor/ScriptBodyEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import Editor, { type BeforeMount } from '@monaco-editor/react' import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme' import { Spinner } from '@/components/common/Spinner' @@ -10,11 +10,22 @@ interface Props { } export function ScriptBodyEditor({ value, onChange, disabled }: Props) { + const lastValueRef = useRef(value) + lastValueRef.current = value + const handleBeforeMount: BeforeMount = useCallback((monaco) => { // Register our dark theme if not already defined monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme) }, []) + const handleChange = useCallback((v: string | undefined) => { + const next = v ?? '' + // Only propagate user-initiated edits, not echoes from external value prop changes + if (next !== lastValueRef.current) { + onChange(next) + } + }, [onChange]) + return (
onChange(v ?? '')} + onChange={handleChange} beforeMount={handleBeforeMount} loading={
From af2a41830ce6a5a96f062817be7538d4cf8f6236 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 22:33:47 +0000 Subject: [PATCH 14/49] docs: add spec and implementation plan for task lane minimize + resolve streaming Co-Authored-By: Claude Opus 4.6 (1M context) --- ...3-28-tasklane-minimize-and-resolve-docs.md | 1066 +++++++++++++++++ ...sklane-minimize-and-resolve-docs-design.md | 158 +++ 2 files changed, 1224 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md create mode 100644 docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md diff --git a/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md new file mode 100644 index 00000000..42a7d66c --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md @@ -0,0 +1,1066 @@ +# Task Lane Minimize/Reopen + Resolve Documentation Streaming — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the task lane collapsible (not destructive) with a reopen pill, and replace the blocking resolve flow with instant resolve + streamed ticket notes generation. + +**Architecture:** Feature 1 is a frontend-only change to TaskLane close behavior + a new pill button. Feature 2 splits the resolve endpoint into a fast status-only call and a new SSE streaming endpoint for AI-generated ticket notes, with prompt caching and singleton client reuse on the backend. + +**Tech Stack:** React, TypeScript, Lucide icons, FastAPI, Anthropic SDK (streaming + prompt caching), SSE via `StreamingResponse` + +--- + +### Task 1: Task Lane — Replace X with PanelRightClose Icon + +**Files:** +- Modify: `frontend/src/components/assistant/TaskLane.tsx:2,250-252` + +- [ ] **Step 1: Update the import to include PanelRightClose** + +Replace line 2 import: + +```tsx +import { + Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, + Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, +} from 'lucide-react' +``` + +(Replace `X` with `PanelRightClose` in the import list.) + +- [ ] **Step 2: Replace the X icon in the close button** + +Replace lines 250-252: + +```tsx + +``` + +- [ ] **Step 3: Verify the icon renders correctly** + +Run the frontend dev server, open an assistant chat, trigger a task lane, and confirm the close button shows a panel-collapse icon instead of an X. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/assistant/TaskLane.tsx +git commit -m "feat: replace task lane X with PanelRightClose icon" +``` + +--- + +### Task 2: Task Lane — Stop Destroying State on Close + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx:797-800` + +- [ ] **Step 1: Remove clearTaskState from the onClose handler** + +Find the TaskLane onClose handler (around line 797-800): + +```tsx + onClose={() => { + setShowTaskLane(false) + if (activeChatId) clearTaskState(activeChatId) + }} +``` + +Replace with: + +```tsx + onClose={() => { + setShowTaskLane(false) + }} +``` + +This keeps task state in sessionStorage and backend `pending_task_lane` so it can be restored. + +- [ ] **Step 2: Verify state persists after closing** + +1. Open assistant chat, trigger task lane with questions/actions +2. Type partial answers into the task lane +3. Click the PanelRightClose button +4. Task lane disappears but answers should persist in sessionStorage + +Verify: `sessionStorage.getItem('rf-tasklane-meta')` still has `questions` and `actions` with the chatId. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: task lane close preserves state instead of clearing" +``` + +--- + +### Task 3: Task Lane — Add Reopen Pill to Input Toolbar + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx:1-3,738-756` + +- [ ] **Step 1: Add ListChecks to the Lucide import** + +Find the Lucide import at the top of `AssistantChatPage.tsx` (line 3): + +```tsx +import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react' +``` + +Add `ListChecks`: + +```tsx +import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react' +``` + +- [ ] **Step 2: Add the Tasks pill button to the input toolbar** + +Find the input toolbar's left button group (around line 738-756). After the Conclude button block (the `{messages.length >= 2 && (` block that ends around line 755), add the Tasks pill: + +```tsx + {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && ( + + )} +``` + +- [ ] **Step 3: Verify the full collapse/reopen flow** + +1. Open assistant chat, trigger task lane +2. Click PanelRightClose — task lane collapses +3. Confirm "Tasks (N)" pill appears in the input toolbar +4. Click the pill — task lane reopens with previous state intact +5. Confirm pill disappears when task lane is open + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: add Tasks pill to reopen collapsed task lane" +``` + +--- + +### Task 4: Backend — Singleton AsyncAnthropic Client + +**Files:** +- Modify: `backend/app/core/ai_provider.py:168-210` + +- [ ] **Step 1: Add a module-level singleton for the Anthropic client** + +In `backend/app/core/ai_provider.py`, add a module-level cache dict before the `AnthropicProvider` class (before line 168): + +```python +# Singleton client cache — avoids creating new HTTP connections per call +_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {} + + +def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic": + """Return a cached AsyncAnthropic client, creating one if needed.""" + import anthropic + + cache_key = f"{api_key[:8]}:{timeout}" + if cache_key not in _anthropic_clients: + _anthropic_clients[cache_key] = anthropic.AsyncAnthropic( + api_key=api_key, + timeout=timeout, + max_retries=1, + ) + return _anthropic_clients[cache_key] +``` + +- [ ] **Step 2: Update AnthropicProvider to use the singleton** + +Replace the `generate_json` method's client creation (lines 182-188): + +```python + async def generate_json( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> tuple[str, int, int]: + client = _get_anthropic_client(self._api_key, self._timeout) + + response = await client.messages.create( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) + + text = response.content[0].text + input_tokens = response.usage.input_tokens + output_tokens = response.usage.output_tokens + + return text, input_tokens, output_tokens +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/ai_provider.py +git commit -m "perf: singleton AsyncAnthropic client to avoid per-call connection setup" +``` + +--- + +### Task 5: Backend — Add generate_text_stream to AIProvider + +**Files:** +- Modify: `backend/app/core/ai_provider.py:16-55,168-210` + +- [ ] **Step 1: Add the abstract method to the AIProvider base class** + +After the `generate_text` abstract method (after line 55), add: + +```python + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> "AsyncIterator[str]": + """Stream a text response token by token. + + Args: + system_prompt: System-level instruction for the model. + messages: List of message dicts with "role" and "content" keys. + max_tokens: Maximum output tokens. + + Yields: + Text chunks as they are generated. + """ + raise NotImplementedError("Streaming not supported for this provider") + # Make this an async generator to satisfy type checker + yield "" # pragma: no cover +``` + +Also add the import at the top of the file (line 1 area): + +```python +from collections.abc import AsyncIterator +``` + +- [ ] **Step 2: Implement streaming in AnthropicProvider** + +Add the streaming method to `AnthropicProvider` after `generate_text` (after line 210): + +```python + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> AsyncIterator[str]: + client = _get_anthropic_client(self._api_key, self._timeout) + + async with client.messages.stream( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) as stream: + async for text in stream.text_stream: + yield text +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/ai_provider.py +git commit -m "feat: add generate_text_stream to AnthropicProvider for SSE support" +``` + +--- + +### Task 6: Backend — Add Streaming Ticket Notes Generator + +**Files:** +- Modify: `backend/app/services/flowpilot_engine.py` (after `generate_status_update`, around line 1012) + +- [ ] **Step 1: Add the streaming ticket notes function** + +After the `generate_status_update` function (after line 1011), add: + +```python +async def stream_ticket_notes( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> AsyncIterator[str]: + """Stream AI-generated structured ticket notes for a resolved session. + + Yields text chunks suitable for SSE streaming. Uses prompt caching + on the system prompt for fast repeat calls. + """ + session = await _load_session(session_id, user_id, db) + + # Build conversation summary from messages (chat sessions) + # or steps (guided sessions) + messages = session.conversation_messages or [] + if messages: + recent = messages[-20:] # Last 20 messages for richer context + convo_text = "\n".join( + f"{'Engineer' if m['role'] == 'user' else 'AI Assistant'}: {m['content'][:500]}" + for m in recent + if isinstance(m, dict) and "role" in m and "content" in m + ) + else: + # Fall back to steps for guided sessions + steps_summary = [] + for step in sorted(session.steps, key=lambda s: s.step_order): + content = step.content or {} + text = content.get("text", "") + response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None) + entry = f"Step {step.step_order + 1}: {text}" + if response: + entry += f"\n Engineer response: {response}" + steps_summary.append(entry) + convo_text = "\n".join(steps_summary) if steps_summary else "No session data." + + # Calculate time spent + now = datetime.now(timezone.utc) + ref_time = session.resolved_at or now + delta = ref_time - session.created_at + total_minutes = int(delta.total_seconds() / 60) + time_display = f"{total_minutes} minutes" if total_minutes < 60 else f"{total_minutes // 60}h {total_minutes % 60}m" + + system_prompt = """You are generating internal ticket notes for an MSP engineer's PSA system. + +Generate EXACTLY these four markdown sections, in this order: + +## Problem Summary +Summarize what the engineer reported and the initial symptoms. 1-3 sentences. + +## Steps Taken +List the key diagnostic steps, commands run, checks performed, and findings. Use bullet points. + +## Resolution +What fixed the issue or what the final action was. Be specific and technical. + +## Next Steps +Any follow-up items, monitoring to watch, or preventive measures. Write "None" if not applicable. + +Rules: +- Be technical, concise, and factual +- Use markdown formatting (headers, bullet lists, bold for emphasis) +- Include specific technical details (commands, settings, error messages) where available +- Do NOT include greetings, sign-offs, or pleasantries +- Do NOT wrap output in code fences +- Output ONLY the four sections above, nothing else""" + + user_message_parts = [ + f"Session status: {session.status}", + f"Time spent: {time_display}", + f"Problem summary: {session.problem_summary or 'Not specified'}", + ] + if session.problem_domain: + user_message_parts.append(f"Problem domain: {session.problem_domain}") + if session.resolution_summary: + user_message_parts.append(f"Resolution notes: {session.resolution_summary}") + user_message_parts.append(f"\nSession conversation:\n{convo_text}") + + user_message = "\n".join(user_message_parts) + + provider = get_ai_provider(settings.get_model_for_action("quick_action")) + + # Use streaming if provider supports it (Anthropic), otherwise fall back + try: + async for chunk in provider.generate_text_stream( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ): + yield chunk + except NotImplementedError: + # Fallback for non-streaming providers (Gemini) + text, _, _ = await provider.generate_text( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ) + yield text +``` + +Also add the import at the top of the file if not already present: + +```python +from collections.abc import AsyncIterator +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/services/flowpilot_engine.py +git commit -m "feat: add stream_ticket_notes generator for SSE doc streaming" +``` + +--- + +### Task 7: Backend — Add SSE Streaming Endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/ai_sessions.py` (after `get_documentation`, around line 925) + +- [ ] **Step 1: Add the SSE streaming endpoint** + +After the `get_documentation` endpoint (after line 924), add: + +```python +@router.get("/{session_id}/documentation/stream") +@limiter.limit("20/minute") +async def stream_documentation( + request: Request, + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Stream AI-generated ticket notes as Server-Sent Events.""" + from starlette.responses import StreamingResponse + + # Verify session ownership + result = await db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized") + + async def event_generator(): + try: + async for chunk in flowpilot_engine.stream_ticket_notes( + session_id=session_id, + user_id=current_user.id, + db=db, + ): + # SSE format: data: \n\n + yield f"data: {chunk}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + logger.exception("SSE stream error for session %s: %s", session_id, e) + yield f"data: [ERROR] {str(e)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + }, + ) +``` + +Also add the `AISession` import at the top if not already there (it should be — verify with the existing imports around line 30-55): + +```python +from app.models.ai_session import AISession +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/api/endpoints/ai_sessions.py +git commit -m "feat: add SSE endpoint for streaming ticket notes generation" +``` + +--- + +### Task 8: Backend — Make resolve_session Non-Blocking + +**Files:** +- Modify: `backend/app/api/endpoints/ai_sessions.py:413-446` +- Modify: `backend/app/schemas/ai_session.py:135-142` + +- [ ] **Step 1: Make documentation optional in SessionCloseResponse** + +In `backend/app/schemas/ai_session.py`, change line 139: + +```python +class SessionCloseResponse(BaseModel): + """Response after resolving or escalating.""" + session_id: UUID + status: str + documentation: SessionDocumentation | None = None + psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed + psa_push_error: str | None = None + member_mapping_warning: str | None = None +``` + +(Change `documentation: SessionDocumentation` to `documentation: SessionDocumentation | None = None`) + +- [ ] **Step 2: Update the frontend type to match** + +In `frontend/src/types/ai-session.ts`, change line 128: + +```typescript +export interface SessionCloseResponse { + session_id: string + status: string + documentation: SessionDocumentation | null + psa_push_status: string + psa_push_error: string | null + member_mapping_warning: string | null +} +``` + +(Change `documentation: SessionDocumentation` to `documentation: SessionDocumentation | null`) + +- [ ] **Step 3: Update the resolve endpoint to not generate docs inline** + +The `resolve_session` endpoint (lines 413-446) currently calls `flowpilot_engine.resolve_session()` which generates `_generate_documentation(session)` inline. The resolve_session in flowpilot_engine already sets status + saves summary + returns docs — we just need the endpoint to stop depending on docs for its response. + +In `backend/app/services/flowpilot_engine.py`, the `resolve_session` function (line 497) already returns `SessionCloseResponse` with `documentation=documentation`. Since we made `documentation` optional, the existing flow still works but is no longer blocking on an LLM call (it never was — `_generate_documentation` is pure Python and fast). The slow part was `generate_session_embedding` (Voyage AI embedding call) and `_push_to_psa`. + +The actual fix: move the embedding generation to be fire-and-forget in the endpoint. In `backend/app/api/endpoints/ai_sessions.py`, replace the resolve_session endpoint (lines 413-446): + +```python +@router.post("/{session_id}/resolve", response_model=SessionCloseResponse) +@limiter.limit("15/minute") +async def resolve_session( + request: Request, + session_id: UUID, + data: ResolveSessionRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Resolve a session. Returns immediately; use /documentation/stream for ticket notes.""" + try: + result = await flowpilot_engine.resolve_session( + session_id=session_id, + request=data, + user_id=current_user.id, + db=db, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + + await db.commit() + + # Fire-and-forget: resolution outputs + embedding (don't block the response) + import asyncio + + async def _post_resolve_tasks(): + try: + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(db) + await gen.generate_all(session_id) + except Exception: + logger.exception(f"Failed to generate resolution outputs for session {session_id}") + + asyncio.create_task(_post_resolve_tasks()) + + return result +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/api/endpoints/ai_sessions.py backend/app/schemas/ai_session.py frontend/src/types/ai-session.ts +git commit -m "feat: make resolve endpoint non-blocking, documentation optional" +``` + +--- + +### Task 9: Frontend — Add streamDocumentation to API Client + +**Files:** +- Modify: `frontend/src/api/aiSessions.ts:95-100` + +- [ ] **Step 1: Add the streaming API method** + +After the `getDocumentation` method (around line 100), add: + +```typescript + async streamDocumentation( + sessionId: string, + onChunk: (text: string) => void, + onDone: () => void, + onError: (error: string) => void, + ): Promise { + const token = localStorage.getItem('access_token') + const baseUrl = import.meta.env.VITE_API_URL || '' + + try { + const response = await fetch( + `${baseUrl}/api/ai-sessions/${sessionId}/documentation/stream`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + + if (!response.ok) { + onError(`HTTP ${response.status}`) + return + } + + const reader = response.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data === '[DONE]') { + onDone() + return + } + if (data.startsWith('[ERROR]')) { + onError(data.slice(8)) + return + } + onChunk(data) + } + } + } + // Stream ended without [DONE] + onDone() + } catch (err) { + onError(err instanceof Error ? err.message : 'Stream failed') + } + }, +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/api/aiSessions.ts +git commit -m "feat: add streamDocumentation SSE client for ticket notes" +``` + +--- + +### Task 10: Frontend — Rewrite ConcludeSessionModal for Two-Phase Flow + +**Files:** +- Modify: `frontend/src/components/assistant/ConcludeSessionModal.tsx` +- Modify: `frontend/src/pages/AssistantChatPage.tsx:396-414` + +- [ ] **Step 1: Update the ConcludeSessionModal props and state** + +The modal's `onConclude` prop currently returns `Promise` (the summary text). We need to change it to return `Promise<{ sessionId: string }>` so the modal can initiate streaming itself. + +Update the interface and imports at the top of `ConcludeSessionModal.tsx`: + +```tsx +import { useState, useEffect, useRef } from 'react' +import { + X, + CheckCircle2, + ArrowUpRight, + Pause, + Loader2, + Copy, + Check, + RefreshCw, + ClipboardList, + Sparkles, + AlertTriangle, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { aiSessionsApi } from '@/api/aiSessions' +import { toast } from '@/lib/toast' + +type ConclusionOutcome = 'resolved' | 'escalated' | 'paused' + +interface ConcludeSessionModalProps { + isOpen: boolean + onClose: () => void + onConclude: (outcome: ConclusionOutcome, notes: string) => Promise + onResumeNew: (summary: string) => void + chatTitle: string + sessionId: string | null +} +``` + +(Added `sessionId` prop, `useRef` import, `AlertTriangle` icon, `aiSessionsApi` import, `toast` import.) + +- [ ] **Step 2: Rewrite the component state and handleGenerate** + +Replace the state declarations and `handleGenerate` function (lines 66-106): + +```tsx +export function ConcludeSessionModal({ + isOpen, + onClose, + onConclude, + onResumeNew, + chatTitle, + sessionId, +}: ConcludeSessionModalProps) { + const [step, setStep] = useState('select-outcome') + const [outcome, setOutcome] = useState(null) + const [notes, setNotes] = useState('') + const [summary, setSummary] = useState('') + const [generating, setGenerating] = useState(false) + const [streaming, setStreaming] = useState(false) + const [streamError, setStreamError] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(null) + const summaryRef = useRef('') + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setStep('select-outcome') + setOutcome(null) + setNotes('') + setSummary('') + summaryRef.current = '' + setGenerating(false) + setStreaming(false) + setStreamError(null) + setCopied(false) + setError(null) + } + }, [isOpen]) + + const handleOutcomeSelect = (selected: ConclusionOutcome) => { + setOutcome(selected) + setStep('add-notes') + } + + const handleGenerate = async () => { + if (!outcome) return + setGenerating(true) + setError(null) + + try { + // Phase 1: Resolve/escalate/pause the session (fast) + await onConclude(outcome, notes) + + // Phase 2: Transition to summary step immediately + setStep('summary') + setGenerating(false) + + // For resolved sessions, stream ticket notes + if (outcome === 'resolved' && sessionId) { + setStreaming(true) + setStreamError(null) + summaryRef.current = '' + + aiSessionsApi.streamDocumentation( + sessionId, + (chunk) => { + summaryRef.current += chunk + setSummary(summaryRef.current) + }, + () => { + setStreaming(false) + }, + (err) => { + setStreaming(false) + setStreamError(err) + // Try non-streaming fallback + aiSessionsApi.getDocumentation(sessionId).then((doc) => { + const fallback = `## Problem Summary\n${doc.problem_summary}\n\n## Steps Taken\n${doc.diagnostic_steps.map(s => `- ${s.description}`).join('\n')}\n\n## Resolution\n${doc.resolution_summary || 'See conversation'}\n\n## Next Steps\nNone` + setSummary(fallback) + setStreamError(null) + }).catch(() => { + // Final fallback — just show what we have + if (!summaryRef.current) { + setSummary('Documentation generation failed. You can copy the conversation from the chat.') + } + }) + }, + ) + } else if (outcome === 'escalated') { + setSummary('Session escalated. Ticket notes will be generated when the session is resolved.') + } else { + setSummary('Session paused. Progress saved — you can resume anytime.') + } + } catch { + setError('Failed to conclude session. Please try again.') + setGenerating(false) + } + } +``` + +- [ ] **Step 3: Update the summary step rendering** + +Replace the summary step content (lines 298-325) with: + +```tsx + {/* Step 3: Summary */} + {step === 'summary' && ( +
+ {/* Outcome badge */} + {selectedOutcome && ( +
+ + {selectedOutcome.label} +
+ )} + + {/* Generated ticket notes */} +
+
+ + + Ticket Notes + + {streaming && ( + + )} +
+ + {/* Streaming content or skeleton */} + {summary ? ( +
+ +
+ ) : streaming ? ( +
+
+
+
+
+
+
+
+ ) : streamError ? ( +
+ + {streamError} +
+ ) : null} +
+
+ )} +``` + +- [ ] **Step 4: Update the footer for the summary step** + +Replace the summary step footer (lines 373-416) with: + +```tsx + {step === 'summary' && ( + <> +
+ {outcome === 'paused' && ( + + )} +
+
+ {summary && !streaming && ( + + )} + +
+ + )} +``` + +- [ ] **Step 5: Update handleConclude in AssistantChatPage** + +In `frontend/src/pages/AssistantChatPage.tsx`, update `handleConclude` (lines 396-414) to return immediately without waiting for documentation: + +```tsx + const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { + if (!activeChatId) throw new Error('No active chat') + + if (outcome === 'resolved') { + await aiSessionsApi.resolveSession(activeChatId, { + resolution_summary: _notes || 'Resolved via assistant chat', + }) + return activeChatId + } else if (outcome === 'escalated') { + await aiSessionsApi.escalateSession(activeChatId, { + escalation_reason: _notes || 'Escalated from assistant chat', + }) + return activeChatId + } else { + await aiSessionsApi.pauseSession(activeChatId) + return activeChatId + } + } +``` + +- [ ] **Step 6: Pass sessionId to ConcludeSessionModal** + +Find where `ConcludeSessionModal` is rendered (around line 812-818) and add the `sessionId` prop: + +```tsx + setShowConclude(false)} + onConclude={handleConclude} + onResumeNew={handleResumeNew} + chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'} + sessionId={activeChatId} + /> +``` + +- [ ] **Step 7: Verify the full flow** + +1. Open assistant chat, have a conversation +2. Click Conclude → Resolved → add optional notes → Generate Summary +3. Modal should transition to summary step IMMEDIATELY +4. Skeleton loading should appear, then ticket notes stream in progressively +5. When streaming completes, "Copy to Clipboard" button appears +6. Click copy → toast "Ticket notes copied" → clipboard has the markdown + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/components/assistant/ConcludeSessionModal.tsx frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: two-phase resolve with streaming ticket notes generation" +``` + +--- + +### Task 11: Frontend Build Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run the frontend build** + +```bash +export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20 +cd frontend && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. If OOM occurs (VPS memory constraint), run `npx tsc --noEmit` instead as a lighter check. + +- [ ] **Step 2: Fix any TypeScript errors** + +If errors appear, fix unused imports, missing props, or type mismatches. Common issues: +- `ConcludeSessionModal` now requires `sessionId` prop — verify all usages pass it +- `SessionCloseResponse.documentation` is now optional — verify `.documentation?.` optional chaining everywhere it's accessed +- Unused `X` import in TaskLane after replacing with `PanelRightClose` + +- [ ] **Step 3: Commit fixes if needed** + +```bash +git add -A +git commit -m "fix: resolve TypeScript errors from task lane and resolve docs changes" +``` + +--- + +### Task 12: Remove Debug Logging from AssistantChatPage + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx` + +- [ ] **Step 1: Remove the console.log statements added during debugging** + +Remove these debug lines: +- `console.log('[AssistantChat] Mount restore check — ...')` (around line 105) +- `console.log('[AssistantChat] Calling selectChat to restore:', ...)` (around line 107) +- `console.log('[AssistantChat] selectChat called with:', ...)` (around line 212) +- `console.log('[AssistantChat] getSession response — ...')` (around line 220) +- `console.error('[AssistantChat] Failed to load chat session:', err)` (around line 243) + +Keep the `catch (err)` form (not bare `catch`) but remove the `console.error` line. Revert to bare `catch`: + +```tsx + } catch { + setMessages([]) + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "chore: remove debug logging from AssistantChatPage" +``` + +--- + +### Task 13: Final Integration Test + +**Files:** None (manual testing) + +- [ ] **Step 1: Test task lane minimize/reopen flow** + +1. Start or continue a chat that has task lane items +2. Click PanelRightClose — panel collapses, "Tasks (N)" pill appears in toolbar +3. Click pill — panel reopens with previous state (including partial answers) +4. Navigate away from assistant page and back — task lane restores from backend +5. Close and reopen — state persists + +- [ ] **Step 2: Test resolve with streaming ticket notes** + +1. Have a multi-message conversation +2. Click Conclude → Resolved → optional notes → Generate Summary +3. Modal transitions instantly to summary step with skeleton +4. Ticket notes stream in with four sections: Problem Summary, Steps Taken, Resolution, Next Steps +5. Copy button appears after streaming completes +6. Click Copy → toast confirms → paste into text editor to verify markdown + +- [ ] **Step 3: Test fallback behavior** + +1. If streaming fails (e.g., AI key missing), verify fallback to non-streaming doc generation +2. If both fail, verify "Documentation generation failed" message appears + +- [ ] **Step 4: Push to PR** + +```bash +git push +``` diff --git a/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md b/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md new file mode 100644 index 00000000..ac1e5b09 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-tasklane-minimize-and-resolve-docs-design.md @@ -0,0 +1,158 @@ +# Task Lane Minimize/Reopen + Resolve Documentation Streaming + +**Date:** 2026-03-28 +**Status:** Approved + +--- + +## Problem + +Two UX issues in the Assistant Chat page: + +1. **Task Lane close destroys state.** The X button calls `clearTaskState()` and hides the panel. There's no way to bring it back without getting a new AI response with markers. Engineers lose in-progress task responses. + +2. **Conclude → Resolved hangs.** The resolve flow blocks on an LLM call to generate documentation. The modal shows "Generating..." for 2-5s+ with no progressive feedback. The generated output needs to be structured ticket notes engineers can copy into their PSA. + +--- + +## Feature 1: Task Lane Minimize/Reopen + +### Close Button Change + +- Replace the X icon (`X` from Lucide) with `PanelRightClose` to signal "collapse" not "destroy." +- On click: set `showTaskLane = false`. Do NOT call `clearTaskState(sessionId)`. +- Task state (questions, actions, user responses) remains in sessionStorage and backend `pending_task_lane`. + +### Reopen Pill on Input Toolbar + +- New pill button in the chat input toolbar row, alongside Attach / Paste Logs / Conclude. +- **Visibility condition:** `(activeQuestions.length > 0 || activeActions.length > 0) && !showTaskLane` +- **Label:** `Tasks (N)` where N = `activeQuestions.length + activeActions.length` +- **Icon:** `ListChecks` from Lucide +- **Action:** `setShowTaskLane(true)` +- **Style:** Same ghost button style as Attach/Paste Logs — muted text, hover highlight. + +### Files Changed + +| File | Change | +|------|--------| +| `frontend/src/components/assistant/TaskLane.tsx` | Replace `X` icon with `PanelRightClose` in header | +| `frontend/src/pages/AssistantChatPage.tsx` | Remove `clearTaskState()` from onClose handler; add Tasks pill to input toolbar | + +--- + +## Feature 2: Resolve with Streaming Documentation + +### Two-Phase Resolve Flow + +**Phase 1 — Instant resolve:** + +1. User clicks Resolve in `ConcludeSessionModal`, enters optional notes, confirms. +2. Frontend calls `POST /ai-sessions/{id}/resolve` with `{ resolution_summary }`. +3. Backend sets session status to `resolved`, saves summary, returns immediately. No LLM call on this path. +4. Modal transitions to the summary step instantly, showing a skeleton loading state for "Ticket Notes." + +**Phase 2 — Streamed doc generation:** + +1. Immediately after phase 1, frontend opens an SSE connection to `GET /ai-sessions/{id}/documentation/stream`. +2. Backend streams the structured ticket notes as they generate, token by token. +3. Frontend renders chunks progressively into the summary step using `MarkdownContent`. +4. When stream completes, show a "Copy to Clipboard" button. + +### Ticket Notes Format + +The LLM generates four structured sections: + +```markdown +## Problem Summary +[What the engineer reported / intake context] + +## Steps Taken +[Key diagnostic steps and findings from conversation] + +## Resolution +[What fixed it / final action taken] + +## Next Steps +[Follow-up items, if any — or "None"] +``` + +### Fallback Behavior + +- If SSE stream fails or times out (30s): fall back to non-streaming `GET /ai-sessions/{id}/documentation`. +- If that also fails: show "Documentation unavailable" with a "Copy Conversation" button that formats the raw `conversation_messages` into a basic structured summary (no LLM, pure template). + +### Copy to Clipboard + +- Prominent button below the rendered ticket notes. +- Copies the full markdown text. +- Toast confirmation: "Ticket notes copied." + +### AI Optimizations + +**1. Streaming (SSE endpoint):** +- New endpoint: `GET /ai-sessions/{id}/documentation/stream` +- Returns `text/event-stream` via FastAPI `StreamingResponse`. +- Uses Anthropic's `client.messages.stream()` for token-level streaming. +- Time-to-first-byte drops from ~2-5s to ~200ms. + +**2. Prompt caching:** +- Apply `cache_control: {"type": "ephemeral"}` to the system prompt and conversation context prefix in the documentation generation call. +- Pattern already exists in `assistant_chat_service.py` (`_call_anthropic_cached`). +- Repeat calls for the same session (regenerate, different doc types) hit cache — up to 85% faster, 90% cheaper on input tokens. + +**3. Client reuse:** +- Singleton `AsyncAnthropic` client instead of creating a new one per call. +- Eliminates connection setup overhead. + +**Model tier:** Haiku (already configured as `quick_action` tier) — fastest model, appropriate for summarization. + +### Backend Changes + +| File | Change | +|------|--------| +| `backend/app/api/endpoints/ai_sessions.py` | Modify `resolve_session` to only set status + save summary (remove the blocking `get_session_documentation` call); add new SSE streaming endpoint for doc generation | +| `backend/app/services/flowpilot_engine.py` | Add `stream_session_documentation()` generator function using `client.messages.stream()` | +| `backend/app/core/ai_provider.py` | Add `generate_text_stream()` method returning async iterator; singleton client | +| `backend/app/services/flowpilot_engine.py` | Add prompt caching to `_build_status_update_prompt` call path | + +### Frontend Changes + +| File | Change | +|------|--------| +| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Two-phase flow: instant resolve → streaming doc render with skeleton → copy button | +| `frontend/src/api/aiSessions.ts` | Add `streamDocumentation(sessionId)` using fetch + ReadableStream | +| `frontend/src/pages/AssistantChatPage.tsx` | Update `handleConclude` to not await documentation | + +### ConcludeSessionModal Summary Step Layout + +``` +┌─────────────────────────────────────┐ +│ Session Resolved │ +│ │ +│ ┌─ Ticket Notes ─────────────────┐ │ +│ │ ## Problem Summary │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Steps Taken │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Resolution │ │ +│ │ [streaming text...] │ │ +│ │ │ │ +│ │ ## Next Steps │ │ +│ │ [streaming text...] │ │ +│ └────────────────────────────────┘ │ +│ │ +│ [ Copy to Clipboard ] [ Done ] │ +└─────────────────────────────────────┘ +``` + +--- + +## Out of Scope + +- Client-facing update generation (future feature, different audience prompt) +- Direct PSA posting (requires ConnectWise integration to be wired to sessions) +- Task lane drag-to-reorder +- Task lane persistence across browser tabs (sessionStorage is per-tab by design) From c6772c6607fb384cfeee2d52639be846c0eec241 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:02:07 +0000 Subject: [PATCH 15/49] perf: singleton AsyncAnthropic client to avoid per-call connection setup Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/core/ai_provider.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py index 4e38cceb..7453ec9c 100644 --- a/backend/app/core/ai_provider.py +++ b/backend/app/core/ai_provider.py @@ -165,6 +165,24 @@ class GeminiProvider(AIProvider): return text, input_tokens, output_tokens +# Singleton client cache — avoids creating new HTTP connections per call +_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {} + + +def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic": + """Return a cached AsyncAnthropic client, creating one if needed.""" + import anthropic + + cache_key = f"{api_key[:8]}:{timeout}" + if cache_key not in _anthropic_clients: + _anthropic_clients[cache_key] = anthropic.AsyncAnthropic( + api_key=api_key, + timeout=timeout, + max_retries=1, + ) + return _anthropic_clients[cache_key] + + class AnthropicProvider(AIProvider): """Anthropic Claude provider using the anthropic SDK.""" @@ -179,13 +197,7 @@ class AnthropicProvider(AIProvider): messages: list[dict[str, str]], max_tokens: int = 4096, ) -> tuple[str, int, int]: - import anthropic - - client = anthropic.AsyncAnthropic( - api_key=self._api_key, - timeout=self._timeout, - max_retries=1, - ) + client = _get_anthropic_client(self._api_key, self._timeout) response = await client.messages.create( model=self._model, From c47b8d26e555b04b5fc32bd1815c6dd1082da597 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:02:14 +0000 Subject: [PATCH 16/49] feat: replace task lane X with PanelRightClose icon Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/assistant/TaskLane.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 791eba74..e361b66e 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, - Send, Clipboard, Loader2, X, MessageCircleQuestion, Eye, + Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' @@ -247,8 +247,8 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`} -
From ca686c0901f8591e8ca1e0ea2a4e70f13745a4ed Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:02:20 +0000 Subject: [PATCH 17/49] feat: task lane close preserves state instead of clearing Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 30e20a2b..abb1b39d 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -801,7 +801,6 @@ export default function AssistantChatPage() { onSubmit={handleTaskSubmit} onClose={() => { setShowTaskLane(false) - if (activeChatId) clearTaskState(activeChatId) }} loading={loading} /> From 2f3781bfc22a89b8a967a3c1a31b41c634f4ace2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:02:35 +0000 Subject: [PATCH 18/49] feat: add generate_text_stream to AnthropicProvider for SSE support Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/core/ai_provider.py | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py index 7453ec9c..84d4c33b 100644 --- a/backend/app/core/ai_provider.py +++ b/backend/app/core/ai_provider.py @@ -7,6 +7,7 @@ backends for JSON generation used by the AI Flow Builder. import logging from abc import ABC, abstractmethod +from collections.abc import AsyncIterator from app.core.config import settings @@ -54,6 +55,26 @@ class AIProvider(ABC): """ ... + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> "AsyncIterator[str]": + """Stream a text response token by token. + + Args: + system_prompt: System-level instruction for the model. + messages: List of message dicts with "role" and "content" keys. + max_tokens: Maximum output tokens. + + Yields: + Text chunks as they are generated. + """ + raise NotImplementedError("Streaming not supported for this provider") + # Make this an async generator to satisfy type checker + yield "" # pragma: no cover + class GeminiProvider(AIProvider): """Google Gemini provider using the google-genai SDK.""" @@ -221,6 +242,23 @@ class AnthropicProvider(AIProvider): # Anthropic doesn't differentiate between JSON and text mode return await self.generate_json(system_prompt, messages, max_tokens) + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> AsyncIterator[str]: + client = _get_anthropic_client(self._api_key, self._timeout) + + async with client.messages.stream( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) as stream: + async for text in stream.text_stream: + yield text + def get_ai_provider(model: str | None = None) -> AIProvider: """Factory that returns the configured AI provider. From bb4743568b8f743ca84ed21e87a11b13f66fd329 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:02:44 +0000 Subject: [PATCH 19/49] feat: add Tasks pill to reopen collapsed task lane Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index abb1b39d..759f3627 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' -import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react' +import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' import type { PendingUpload } from '@/types/upload' @@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatMessage } from '@/components/assistant/ChatMessage' -import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' +import { TaskLane } from '@/components/assistant/TaskLane' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' @@ -758,6 +758,17 @@ export default function AssistantChatPage() { Conclude )} + {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && ( + + )}
)} - {/* Generated summary */} + {/* Generated ticket notes */}
- Generated Ticket Notes + Ticket Notes + {streaming && ( + + )}
-
- -
+ + {/* Streaming content or skeleton */} + {summary ? ( +
+ +
+ ) : streaming ? ( +
+
+
+
+
+
+
+
+ ) : streamError ? ( +
+ + {streamError} +
+ ) : null}
)} @@ -384,27 +453,29 @@ export function ConcludeSessionModal({ )}
- + {summary && !streaming && ( + + )}
From 8c7a863f5ff8563b9aff3b0b6ef72aa12f044056 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:09:26 +0000 Subject: [PATCH 25/49] chore: remove debug logging from AssistantChatPage Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index d280c0cf..b7afcb7f 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -102,9 +102,7 @@ export default function AssistantChatPage() { // Restore session from sessionStorage on mount (when URL has no session ID) useEffect(() => { - console.log('[AssistantChat] Mount restore check — urlSessionId:', urlSessionId, 'activeChatId:', activeChatId) if (!urlSessionId && activeChatId) { - console.log('[AssistantChat] Calling selectChat to restore:', activeChatId) selectChat(activeChatId) } }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -209,7 +207,6 @@ export default function AssistantChatPage() { } const selectChat = useCallback(async (chatId: string) => { - console.log('[AssistantChat] selectChat called with:', chatId) setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available setShowTaskLane(false) @@ -217,7 +214,6 @@ export default function AssistantChatPage() { setActiveActions([]) try { const detail = await aiSessionsApi.getSession(chatId) - console.log('[AssistantChat] getSession response — messages:', detail.conversation_messages?.length, 'pending_task_lane:', !!detail.pending_task_lane) setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', @@ -241,8 +237,7 @@ export default function AssistantChatPage() { } } } - } catch (err) { - console.error('[AssistantChat] Failed to load chat session:', err) + } catch { setMessages([]) } }, []) From 564d88e90f93ad37ef56df773f0988ee1f0a95ad Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 23:25:08 +0000 Subject: [PATCH 26/49] fix: update useFlowPilotSession return types for optional documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionCloseResponse.documentation is now nullable — update resolve/escalate return types to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useFlowPilotSession.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useFlowPilotSession.ts b/frontend/src/hooks/useFlowPilotSession.ts index 6034cbb0..873b3846 100644 --- a/frontend/src/hooks/useFlowPilotSession.ts +++ b/frontend/src/hooks/useFlowPilotSession.ts @@ -27,8 +27,8 @@ export interface UseFlowPilotSession { // Actions startSession: (intake: AISessionCreateRequest) => Promise respondToStep: (response: StepResponseRequest) => Promise - resolveSession: (data: ResolveSessionRequest) => Promise - escalateSession: (data: EscalateSessionRequest) => Promise + resolveSession: (data: ResolveSessionRequest) => Promise + escalateSession: (data: EscalateSessionRequest) => Promise pauseSession: () => Promise resumeOwnSession: () => Promise abandonSession: () => Promise @@ -134,7 +134,7 @@ export function useFlowPilotSession(): UseFlowPilotSession { } }, [session]) - const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise => { + const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise => { if (!session) throw new Error('No active session') setIsProcessing(true) try { @@ -156,7 +156,7 @@ export function useFlowPilotSession(): UseFlowPilotSession { } }, [session]) - const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise => { + const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise => { if (!session) throw new Error('No active session') setIsProcessing(true) try { From c3a53f2f30f65f1cd6799c3856aba892b3bfc3d0 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 29 Mar 2026 05:35:08 +0000 Subject: [PATCH 27/49] docs: add parameterize-and-save design spec Design for fixing AI-generated scripts saving to library without parameters, adding parameter detection/review to the save flow, and a "New from Script" paste entry point on the library page. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-03-29-parameterize-and-save-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md diff --git a/docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md b/docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md new file mode 100644 index 00000000..a78b1867 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md @@ -0,0 +1,165 @@ +# Parameterize & Save — Script Library Integration + +> **Date:** 2026-03-29 +> **Status:** Approved +> **Scope:** Fix AI-generated scripts saving to library without parameters; add parameter detection/review to save flow; add "New from Script" paste entry point + +--- + +## Problem + +When the AI Script Builder generates a script and the user saves it to the Script Library: + +1. **`parameters_schema` is hardcoded to `{"parameters": []}`** in `save_to_library()` — no parameter detection runs +2. **The script body uses raw PowerShell `param()` syntax**, not the `{{ key }}` template placeholders that `ScriptTemplateEngine.render()` expects + +The result: saved templates have no parameters and can't be rendered with user-provided values. The template engine has nothing to substitute. + +The frontend already has working parameter detection (`scriptParameterDetector.ts`) and a review UI (`ParameterDetectorStepper`), but they're only wired into `ScriptTemplateEditor` — not the save-to-library flow. + +## Solution + +A shared `ParameterizeAndSavePanel` component that: + +1. Shows the script with live preview of `{{ }}` replacements +2. Auto-runs parameter detection and lets the user review/accept/skip each candidate +3. Collects minimal metadata (name, description, category, share toggle) +4. Sends the rewritten script body + built parameters schema to the backend + +Used in two entry points: +- **AI Script Builder** — replaces the current `SaveToLibraryDialog` +- **Script Library page** — "New from Script" button for pasting raw scripts + +> **Note:** Parameter detection currently supports PowerShell only. Bash and Python scripts will show "No parameters detected" and save as-is. Detection for those languages is planned for a future iteration. + +--- + +## Design + +### `ParameterizeAndSavePanel` Component + +A slide-in panel from the right, ~480px wide, semi-transparent scrim behind it. Close via X button or scrim click. + +**Layout (top to bottom):** + +1. **Script Preview** — read-only code block showing the script body. As parameters are accepted via the stepper, the preview updates live to show `{{ key }}` replacements highlighted in amber. + +2. **Parameter Detection Zone** — auto-runs `detectParameterCandidates()` when the panel opens. + - If candidates found: renders the existing `ParameterDetectorStepper` inline + - If no candidates found: shows "No parameters detected — script will be saved as-is" + +3. **Metadata Fields** — name (required), description, category dropdown, share-with-team toggle. Same fields as the current `SaveToLibraryDialog`. + +4. **Save Button** — sends rewritten script body + parameters schema + metadata to the backend. + +**Two modes controlled by props:** + +- **`script` mode** (from AI builder): script body + language provided, skips straight to preview + detection +- **`paste` mode** (from library page): shows a textarea + language picker at the top of the panel, above the preview area. Once pasted and confirmed, textarea collapses into the read-only preview and detection runs. + +**State:** Local React state (not Zustand). Tracks: +- `workingScript`: the in-progress script body, mutated as candidates are accepted +- `parametersSchema`: accumulated `ScriptParameter[]` array +- `metadata`: name, description, categoryId, shareWithTeam +- `mode`: paste vs script (derived from props) + +### Entry Point 1: AI Script Builder + +`ScriptBuilderPage` currently opens `SaveToLibraryDialog`. Replace with `ParameterizeAndSavePanel` in `script` mode. + +**Props:** +- `scriptBody`: `session.latest_script` +- `language`: `session.language` +- `defaultName`: suggested filename minus extension +- `onSave`: calls `scriptBuilderApi.saveToLibrary()` with enriched payload +- `onClose`: closes the panel + +### Entry Point 2: Script Library Page + +Add a "New from Script" button on `ScriptLibraryPage` (near the "Manage Templates" link). Opens `ParameterizeAndSavePanel` in `paste` mode. + +**Props:** +- `scriptBody`: `undefined` (triggers paste mode) +- `language`: `undefined` (user picks in the panel) +- `onSave`: calls `scriptsApi.createTemplate()` directly +- `onClose`: closes the panel + +### Backend Changes + +**`SaveToLibraryRequest` schema** (`backend/app/schemas/script_builder.py`): + +Add two optional fields: +```python +script_body: str | None = None # Rewritten script with {{ }} placeholders +parameters_schema: dict | None = None # Built parameter schema from frontend +``` + +**`save_to_library()` service** (`backend/app/services/script_builder_service.py`): + +Add `script_body: str | None = None` and `parameters_schema: dict | None = None` to the function signature. Use provided values instead of hardcoding: +```python +template = ScriptTemplate( + ... + script_body=script_body or session.latest_script, + parameters_schema=parameters_schema or {"parameters": []}, + ... +) +``` + +**`save_to_library` endpoint** (`backend/app/api/endpoints/script_builder.py`): + +Pass `data.script_body` and `data.parameters_schema` from the request through to the service function. + +**No new endpoints needed.** The library paste flow uses the existing `POST /scripts/templates` which already accepts `script_body` + `parameters_schema`. + +### Script Rewriting Logic + +The `ParameterizeAndSavePanel` reuses the same approach as `ScriptTemplateEditor.handleAcceptCandidate()`: + +When a candidate is accepted: +1. Find the matched line in the working script +2. Replace the default value portion with `'{{ key }}'` +3. Add the parameter to the accumulated schema + +This happens in the panel's local state. The stepper emits accept/skip events; the panel handles the rewriting. + +--- + +## File Changes + +### New Files +- `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx` — shared panel component + +### Modified Files +- `frontend/src/pages/ScriptBuilderPage.tsx` — replace `SaveToLibraryDialog` with `ParameterizeAndSavePanel` (script mode) +- `frontend/src/pages/ScriptLibraryPage.tsx` — add "New from Script" button, render `ParameterizeAndSavePanel` (paste mode) +- `backend/app/schemas/script_builder.py` — add `script_body` and `parameters_schema` to `SaveToLibraryRequest` +- `backend/app/services/script_builder_service.py` — use provided values in `save_to_library()` +- `backend/app/api/endpoints/script_builder.py` — pass new fields through + +### Deleted Files +- `frontend/src/components/script-builder/SaveToLibraryDialog.tsx` — replaced entirely + +### Unchanged (reused as-is) +- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx` +- `frontend/src/lib/scriptParameterDetector.ts` +- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx` +- `POST /scripts/templates` endpoint + +--- + +## Scope Boundaries + +**In scope:** +- `ParameterizeAndSavePanel` component with script/paste modes +- Parameter detection + stepper review in the save flow +- Script body rewriting with `{{ }}` placeholders +- Backend accepting enriched save payload +- "New from Script" button on library page +- PowerShell parameter detection only + +**Out of scope:** +- Bash/Python parameter detection (future iteration) +- Changes to `ScriptTemplateEditor` or `ParameterSchemaBuilder` +- Changes to `ScriptTemplateEngine` rendering logic +- New API endpoints From 60e1384a2c7788bd9f460b85284f7b6b9351a00d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 29 Mar 2026 05:46:40 +0000 Subject: [PATCH 28/49] docs: add parameterize-and-save implementation plan 7-task plan covering backend schema updates, ParameterizeAndSavePanel component, ScriptBuilderPage integration, ScriptLibraryPage "New from Script" entry point, and SaveToLibraryDialog deletion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-29-parameterize-and-save.md | 937 ++++++++++++++++++ 1 file changed, 937 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-29-parameterize-and-save.md diff --git a/docs/superpowers/plans/2026-03-29-parameterize-and-save.md b/docs/superpowers/plans/2026-03-29-parameterize-and-save.md new file mode 100644 index 00000000..23d36095 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-parameterize-and-save.md @@ -0,0 +1,937 @@ +# Parameterize & Save Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix AI-generated scripts saving to library without parameters by adding parameter detection, user review, and template rewriting to the save flow — plus a "New from Script" paste entry point on the library page. + +**Architecture:** A new `ParameterizeAndSavePanel` slide-in panel component handles two modes: `script` (pre-populated from AI builder) and `paste` (user pastes raw script). It auto-runs `detectParameterCandidates()`, renders `ParameterDetectorStepper` for review, rewrites the script body with `{{ key }}` placeholders, and sends enriched payload to the backend. Backend `save_to_library()` accepts the provided `script_body` + `parameters_schema` instead of hardcoding empty values. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS v4, FastAPI, Pydantic v2, SQLAlchemy 2.0 + +**Spec:** `docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md` + +--- + +### Task 1: Backend — Update `SaveToLibraryRequest` schema and `save_to_library()` service + +**Files:** +- Modify: `backend/app/schemas/script_builder.py:79-84` (SaveToLibraryRequest) +- Modify: `backend/app/services/script_builder_service.py:317-377` (save_to_library function) +- Modify: `backend/app/api/endpoints/script_builder.py:156-188` (save_to_library endpoint) + +- [ ] **Step 1: Update `SaveToLibraryRequest` schema** + +In `backend/app/schemas/script_builder.py`, add two optional fields to `SaveToLibraryRequest`: + +```python +class SaveToLibraryRequest(BaseModel): + """Request to save a generated script to the Script Library.""" + name: str = Field(min_length=1, max_length=200) + description: str | None = None + category_id: UUID | None = None + share_with_team: bool = False + script_body: str | None = None + parameters_schema: dict | None = None +``` + +- [ ] **Step 2: Update `save_to_library()` service function signature and body** + +In `backend/app/services/script_builder_service.py`, add two new parameters to `save_to_library()` and use them in the `ScriptTemplate` constructor: + +```python +async def save_to_library( + db: AsyncSession, + session: ScriptBuilderSession, + name: str, + description: str | None, + category_id: UUID | None, + share_with_team: bool, + user_id: UUID, + team_id: UUID | None, + script_body: str | None = None, + parameters_schema: dict | None = None, +) -> "ScriptTemplate": +``` + +And in the `ScriptTemplate(...)` constructor inside the same function, change the two hardcoded lines: + +```python + template = ScriptTemplate( + id=uuid_mod.uuid4(), + category_id=resolved_category_id, + created_by=user_id, + team_id=team_id if share_with_team else None, + name=name, + slug=slug, + description=description, + script_body=script_body or session.latest_script, + parameters_schema=parameters_schema or {"parameters": []}, + default_values={}, + validation_rules={}, + tags=[session.language, "ai-generated"], + complexity="intermediate", + is_verified=False, + is_active=True, + version=1, + usage_count=0, + ) +``` + +- [ ] **Step 3: Update the endpoint to pass new fields through** + +In `backend/app/api/endpoints/script_builder.py`, update the `save_to_library` endpoint to pass the new fields to the service: + +```python + template = await script_builder_service.save_to_library( + db=db, + session=session, + name=data.name, + description=data.description, + category_id=data.category_id, + share_with_team=data.share_with_team, + user_id=current_user.id, + team_id=current_user.team_id, + script_body=data.script_body, + parameters_schema=data.parameters_schema, + ) +``` + +- [ ] **Step 4: Verify backend still starts cleanly** + +Run: `cd /home/coder/resolutionflow/backend && source venv/bin/activate && python -c "from app.schemas.script_builder import SaveToLibraryRequest; print('OK')"` + +Expected: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/script_builder.py backend/app/services/script_builder_service.py backend/app/api/endpoints/script_builder.py +git commit -m "feat: accept script_body and parameters_schema in save-to-library flow + +Previously save_to_library() hardcoded parameters_schema to empty and +always used session.latest_script. Now accepts optional overrides from +the frontend for parameterized script bodies. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 2: Frontend — Update `SaveToLibraryRequest` type + +**Files:** +- Modify: `frontend/src/types/script-builder.ts:45-50` (SaveToLibraryRequest interface) + +- [ ] **Step 1: Add new fields to the TypeScript interface** + +In `frontend/src/types/script-builder.ts`, update `SaveToLibraryRequest`: + +```typescript +export interface SaveToLibraryRequest { + name: string + description?: string + category_id?: string + share_with_team?: boolean + script_body?: string + parameters_schema?: { parameters: ScriptParameter[] } +} +``` + +Add the import for `ScriptParameter` at the top of the file: + +```typescript +import type { ScriptParameter } from './scripts' +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -30` + +Expected: No errors related to `SaveToLibraryRequest` or `ScriptParameter`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/types/script-builder.ts +git commit -m "feat: add script_body and parameters_schema to SaveToLibraryRequest type + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 3: Frontend — Create `ParameterizeAndSavePanel` component + +**Files:** +- Create: `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx` + +This is the core new component. It handles two modes (`script` and `paste`), auto-runs parameter detection, embeds the existing `ParameterDetectorStepper`, rewrites the script body with `{{ key }}` placeholders, collects metadata, and calls the save handler. + +- [ ] **Step 1: Create the component file** + +Create `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx`: + +```tsx +import { useState, useEffect, useRef, useCallback } from 'react' +import { X, Loader2, FileCode, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { scriptsApi } from '@/api' +import { detectParameterCandidates } from '@/lib/scriptParameterDetector' +import { ParameterDetectorStepper } from '@/components/script-editor/ParameterDetectorStepper' +import type { + ScriptCategoryResponse, + ScriptParameter, + ScriptParametersSchema, + ParameterCandidate, +} from '@/types' + +interface ParameterizeAndSavePanelProps { + /** Pre-populated script body (script mode). Undefined triggers paste mode. */ + scriptBody?: string + /** Script language. Undefined shows language picker in paste mode. */ + language?: string + /** Default name for the template. */ + defaultName?: string + /** Default description for the template. */ + defaultDescription?: string + /** Called with the final enriched payload when user saves. */ + onSave: (payload: { + name: string + description: string | undefined + category_id: string | undefined + share_with_team: boolean + script_body: string + parameters_schema: ScriptParametersSchema + }) => Promise + /** Called when the panel is closed without saving. */ + onClose: () => void +} + +const LANGUAGES = [ + { value: 'powershell', label: 'PowerShell' }, + { value: 'bash', label: 'Bash' }, + { value: 'python', label: 'Python' }, +] + +export function ParameterizeAndSavePanel({ + scriptBody, + language: initialLanguage, + defaultName = '', + defaultDescription = '', + onSave, + onClose, +}: ParameterizeAndSavePanelProps) { + // Mode: script (body provided) vs paste (user pastes) + const isPasteMode = scriptBody === undefined + + // Paste mode state + const [pastedScript, setPastedScript] = useState('') + const [selectedLanguage, setSelectedLanguage] = useState(initialLanguage || 'powershell') + const [scriptConfirmed, setScriptConfirmed] = useState(false) + + // Working state — the script body being rewritten as params are accepted + const effectiveScript = isPasteMode ? pastedScript : scriptBody! + const [workingScript, setWorkingScript] = useState(effectiveScript) + const [parameters, setParameters] = useState([]) + + // Detection state + const [candidates, setCandidates] = useState([]) + const [detectionRan, setDetectionRan] = useState(false) + const [showStepper, setShowStepper] = useState(false) + const [detectionSummary, setDetectionSummary] = useState(null) + + // Metadata state + const [name, setName] = useState(defaultName) + const [description, setDescription] = useState(defaultDescription) + const [categoryId, setCategoryId] = useState('') + const [shareWithTeam, setShareWithTeam] = useState(false) + const [categories, setCategories] = useState([]) + + // Save state + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + const panelRef = useRef(null) + + // Load categories on mount + useEffect(() => { + scriptsApi.getCategories().then(setCategories).catch(() => {}) + }, []) + + // Auto-run detection when script is ready (script mode: on mount, paste mode: after confirm) + const runDetection = useCallback((script: string) => { + const detected = detectParameterCandidates(script) + setCandidates(detected) + setDetectionRan(true) + if (detected.length > 0) { + setShowStepper(true) + } else { + setDetectionSummary('No parameters detected — script will be saved as-is. Parameter detection currently supports PowerShell only.') + } + }, []) + + // Script mode: run detection on mount + useEffect(() => { + if (!isPasteMode && effectiveScript) { + setWorkingScript(effectiveScript) + runDetection(effectiveScript) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Paste mode: run detection after script is confirmed + useEffect(() => { + if (isPasteMode && scriptConfirmed && pastedScript) { + setWorkingScript(pastedScript) + runDetection(pastedScript) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scriptConfirmed]) + + // Escape key closes panel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + const handleConfirmPaste = () => { + if (!pastedScript.trim()) return + setScriptConfirmed(true) + } + + const handleAcceptCandidate = ( + candidate: ParameterCandidate, + overrides: { + key: string + label: string + type: ScriptParameter['type'] + sensitive: boolean + required: boolean + defaultValue: string | boolean | number | null + } + ) => { + // Rewrite the script body — replace the default value with {{ key }} placeholder + let updatedScript = workingScript + if (candidate.source === 'param_block') { + const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/) + if (defaultMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`) + ) + } + } else { + const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/) + if (assignMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`) + ) + } + } + setWorkingScript(updatedScript) + + // Add parameter to accumulated schema + const newParam: ScriptParameter = { + key: overrides.key, + label: overrides.label, + type: overrides.type, + required: overrides.required, + placeholder: null, + group: null, + order: parameters.length + 1, + help_text: null, + options: null, + default: overrides.defaultValue, + validation: null, + sensitive: overrides.sensitive, + } + setParameters(prev => [...prev, newParam]) + } + + const handleSkipCandidate = () => { + // Stepper advances internally — nothing to do here + } + + const handleDetectionFinish = (acceptedCount: number, totalCount: number) => { + setShowStepper(false) + setCandidates([]) + setDetectionSummary( + acceptedCount === 0 + ? 'No parameters were added. Script will be saved as-is.' + : `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.` + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + + setIsSaving(true) + setError(null) + + try { + await onSave({ + name: name.trim(), + description: description.trim() || undefined, + category_id: categoryId || undefined, + share_with_team: shareWithTeam, + script_body: workingScript, + parameters_schema: { parameters }, + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.') + } finally { + setIsSaving(false) + } + } + + // Determine if save button should be enabled + const canSave = name.trim().length > 0 + && !isSaving + && !showStepper + && (isPasteMode ? scriptConfirmed : true) + + return ( + <> + {/* Scrim */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+ +

+ {isPasteMode ? 'Import Script to Library' : 'Save to Library'} +

+
+ +
+ + {/* Scrollable content */} +
+ + {/* Paste mode: script input area (before confirmation) */} + {isPasteMode && !scriptConfirmed && ( +
+

+ Paste Your Script +

+
+ {LANGUAGES.map((lang) => ( + + ))} +
+