From c90e8ce0e89c902acace67ccad6404922d434a7f Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Sun, 17 May 2026 14:09:24 -0400 Subject: [PATCH] plan tool updates Plan tools extended to all modes; recent chat sessions added to Authenticated Home view; drone locking and chat session startup factored out of Project Manager into Chat Session view and made universal; update for AGENTS.md. --- AGENTS.md | 6 + .../data/prompts/agent/build/system.md | 4 + gadget-code/data/prompts/agent/dev/system.md | 4 + gadget-code/data/prompts/agent/plan/system.md | 15 ++ gadget-code/data/prompts/agent/ship/system.md | 4 + gadget-code/data/prompts/agent/test/system.md | 4 + gadget-code/data/prompts/common/subagents.md | 2 + gadget-code/frontend/src/lib/api.ts | 4 +- .../frontend/src/pages/ChatSessionView.tsx | 247 ++++++++++++++---- gadget-code/frontend/src/pages/Home.tsx | 115 +++++++- .../frontend/src/pages/ProjectManager.tsx | 37 +-- .../src/controllers/api/v1/chat-session.ts | 3 +- gadget-code/src/services/chat-session.ts | 14 +- gadget-drone/src/services/agent.test.ts | 20 +- gadget-drone/src/services/agent.ts | 10 +- packages/ai-toolbox/src/plan/edit.ts | 2 +- packages/ai-toolbox/src/plan/list.ts | 2 +- packages/ai-toolbox/src/plan/read.ts | 2 +- packages/ai-toolbox/src/plan/write.ts | 2 +- 19 files changed, 382 insertions(+), 115 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b89968..5ac651d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,3 +114,9 @@ If code is needed by both consumer packages, it belongs in the appropriate share - MongoDB on `localhost:27017` - Redis on `localhost:6379` - SSL certificates in `gadget-code/ssl/` directory (for dev servers) + +## .gadget Directory + +The `.gadget/` directory at the project root contains plans, TODOs, and knowledge +artifacts managed by Gadget Code. Do not modify these files unless you understand +their purpose and format. diff --git a/gadget-code/data/prompts/agent/build/system.md b/gadget-code/data/prompts/agent/build/system.md index 431a4c5..f158644 100644 --- a/gadget-code/data/prompts/agent/build/system.md +++ b/gadget-code/data/prompts/agent/build/system.md @@ -38,6 +38,10 @@ You must remain within the project directory, which is the current working direc {{process_management_block}} +## PLAN ACCESS + +You have access to plans stored in Gadget's .gadget directory. Use `plan_list` to discover existing plans, `plan_file_read` to read them, and `plan_file_edit` to update TODO checklists as you complete work items. Always check for an active plan before starting work. + ## INSTRUCTIONS You always provide regular updates to explain your thinking and reasoning while working and calling tools. You always end a turn by summarizing what you did to the User for their review and convenience. When the user sends you a prompt: diff --git a/gadget-code/data/prompts/agent/dev/system.md b/gadget-code/data/prompts/agent/dev/system.md index a36a52d..0d447fc 100644 --- a/gadget-code/data/prompts/agent/dev/system.md +++ b/gadget-code/data/prompts/agent/dev/system.md @@ -34,6 +34,10 @@ NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT {{process_management_block}} +## PLAN ACCESS + +You have access to plans stored in Gadget's .gadget directory. Use `plan_list` to discover existing plans, `plan_file_read` to read them, and `plan_file_edit` to update TODO checklists as you complete work items. + ## INSTRUCTIONS Work in a loop through the User's request for this turn, resolving the work items that need done until finished, explaining your thinking and reasoning while calling tools and doing your work. diff --git a/gadget-code/data/prompts/agent/plan/system.md b/gadget-code/data/prompts/agent/plan/system.md index f99d202..2e9f456 100644 --- a/gadget-code/data/prompts/agent/plan/system.md +++ b/gadget-code/data/prompts/agent/plan/system.md @@ -40,6 +40,21 @@ Don't announce tool usage, just execute and use findings to inform your planning {{tool_block}} +## PLAN TOOLS + +You have four tools for persisting plans and knowledge in Gadget's .gadget directory: + +- **plan_list** — List contents of the .gadget directory (or a subdirectory). Use it to discover existing plans before creating new ones. +- **plan_file_read** — Read a file from .gadget with line numbers. All paths are relative to the .gadget directory. +- **plan_file_write** — Create or overwrite a file in .gadget. Use for new plans, TODOs, design documents. +- **plan_file_edit** — Search-and-replace edit on a .gadget file. Use for updating existing plans and TODO checklists. + +### Conventions +- Store plans in `.gadget/plans/` (e.g., `plans/feature-name.md`) +- Store TODOs alongside their plans (e.g., `plans/feature-name-todo.md`) +- Paths passed to these tools are **relative to .gadget/**, not the project root +- These tools operate ONLY in .gadget/ — they cannot read or write project source files + {{process_management_block}} ## INSTRUCTIONS diff --git a/gadget-code/data/prompts/agent/ship/system.md b/gadget-code/data/prompts/agent/ship/system.md index a407cbb..4c4dac8 100644 --- a/gadget-code/data/prompts/agent/ship/system.md +++ b/gadget-code/data/prompts/agent/ship/system.md @@ -24,6 +24,10 @@ Use your tools decisively. Run tests, check builds, verify deployments, and exec {{process_management_block}} +## PLAN ACCESS + +You have access to plans stored in Gadget's .gadget directory. Use `plan_list` and `plan_file_read` to find and read active plans. Update TODO checklists with `plan_file_edit` as shipping items are verified. + ## INSTRUCTIONS When the user sends you a prompt: diff --git a/gadget-code/data/prompts/agent/test/system.md b/gadget-code/data/prompts/agent/test/system.md index 5df653a..4a3e7fe 100644 --- a/gadget-code/data/prompts/agent/test/system.md +++ b/gadget-code/data/prompts/agent/test/system.md @@ -24,6 +24,10 @@ Use your tools aggressively for testing. Run tests frequently, read test files, {{process_management_block}} +## PLAN ACCESS + +You have access to plans stored in Gadget's .gadget directory. Use `plan_list` and `plan_file_read` to find and read active plans. Update TODO checklists with `plan_file_edit` as you verify test items. + ## INSTRUCTIONS When the user sends you a prompt: diff --git a/gadget-code/data/prompts/common/subagents.md b/gadget-code/data/prompts/common/subagents.md index 3d87bf7..703becc 100644 --- a/gadget-code/data/prompts/common/subagents.md +++ b/gadget-code/data/prompts/common/subagents.md @@ -17,6 +17,8 @@ Always be specific when asking a subagent to do work. When constructing your pro 5. Define "done" for the agent so it knows when to stop working 6. Tell the agent exactly how to report back to you when done +Subagents have access to the same file, network, and system tools as the main agent. They do **not** have access to `plan_*` tools or the `subagent` tool — those are reserved for the main agent only. + ### SUBAGENT: EXPLORE The Explore subagent is tuned for knowing how to dig through a project directory, find the requested information, and generate a report back to you containing the information you need (if available). diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index 5917bae..2d15bd2 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -469,9 +469,9 @@ export interface ChatTurn { } export const chatSessionApi = { - getAll: (projectId?: string) => + getAll: (projectId?: string, limit?: number) => api.get( - `/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}`, + `/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`, ), get: (id: string) => api.get(`/api/v1/chat-sessions/${id}`), create: (data: { diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index 4e6fc83..bcd2a29 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useContext, useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { socketClient } from '../lib/socket'; import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api'; import { WorkspaceMode } from '../lib/types'; @@ -44,9 +44,23 @@ interface SubagentStreamState { stats?: ChatTurnStats; } +enum SessionStartupState { + Loading = 'loading', + RequestLock = 'requestLock', + LockAcquired = 'lockAcquired', + Ready = 'ready', + LockFailed = 'lockFailed', +} + +interface RouterState { + drone?: any; // DroneRegistration passed via router state + project?: Project; // Project passed via router state (optional — can be fetched) +} + export default function ChatSessionView() { const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>(); const navigate = useNavigate(); + const location = useLocation(); const appContext = useContext(AppContext); const [project, setProject] = useState(null); @@ -57,7 +71,8 @@ export default function ChatSessionView() { const [isAborting, setIsAborting] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [sessionLocked, setSessionLocked] = useState(true); + const [startupState, setStartupState] = useState(SessionStartupState.Loading); + const [droneRegistration, setDroneRegistration] = useState(null); const [workspaceMode, setWorkspaceMode] = useState(WorkspaceMode.Idle); const [isUpdatingMode, setIsUpdatingMode] = useState(false); const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); @@ -95,6 +110,7 @@ export default function ChatSessionView() { const subagentStateRef = useRef>(new Map()); const sessionRef = useRef(null); const projectRef = useRef(null); + const droneRegistrationRef = useRef(null); useEffect(() => { loadSessionData(); @@ -129,15 +145,19 @@ export default function ChatSessionView() { projectRef.current = project; }, [project]); - // Start heartbeat when session+project are loaded useEffect(() => { - if (session && project) { + droneRegistrationRef.current = droneRegistration; + }, [droneRegistration]); + + // Start heartbeat when startup is complete (Ready state) + useEffect(() => { + if (startupState === SessionStartupState.Ready) { socketClient.startSessionHeartbeat(); } return () => { socketClient.stopSessionHeartbeat(); }; - }, [session, project]); + }, [startupState]); // Handle socket reconnection and tab lock const handleSocketConnect = useCallback(() => { @@ -176,12 +196,10 @@ export default function ChatSessionView() { // Release session lock on unmount only useEffect(() => { return () => { - const droneJson = localStorage.getItem('dtp_drone_registration'); - if (droneJson && sessionRef.current && projectRef.current) { + if (droneRegistrationRef.current && sessionRef.current && projectRef.current) { try { - const registration = JSON.parse(droneJson); socketClient.releaseSessionLock( - registration, + droneRegistrationRef.current, projectRef.current, sessionRef.current, ); @@ -193,45 +211,96 @@ export default function ChatSessionView() { }, []); const loadSessionData = async () => { + setStartupState(SessionStartupState.Loading); + setLoading(true); + setError(''); + try { - if (sessionId) { - const sessionData = await chatSessionApi.get(sessionId); - setSession(sessionData); + if (!sessionId) return; - const projectRef = sessionData.project; - const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id; - if (projectObjectId) { - const projectData = await projectApi.get(projectObjectId); - setProject(projectData); - } + // --- State: LOADING — Fetch session, project, turns, providers --- - const turnsData = await chatSessionApi.getTurns(sessionId); - setTurns(turnsData); - setSessionLocked(true); + const sessionData = await chatSessionApi.get(sessionId); + setSession(sessionData); - const allProviders = await providerApi.getAll(); - setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name))); - - const providerId = typeof sessionData.provider === 'string' - ? sessionData.provider - : sessionData.provider?._id; - setSelectedProviderId(providerId || ''); - setSelectedModelId(sessionData.selectedModel || ''); - setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); - - // Initialize numCtx: persisted value wins, otherwise model's contextWindow - if (sessionData.numCtx) { - setSessionNumCtx(sessionData.numCtx); - } else { - const provider = allProviders.find(p => p._id === providerId); - const model = provider?.models.find(m => m.id === sessionData.selectedModel); - setSessionNumCtx(model?.contextWindow || 131072); - } - - // Set connection state to connecting - will update on socket connect - setConnectionState('connecting'); - appContext?.setStatusMessage('Connecting...'); + // Resolve the drone registration: router state > localStorage fallback + const routerState = location.state as RouterState | null; + const droneFromRouter = routerState?.drone; + let droneReg = droneFromRouter || null; + if (!droneReg) { + // Fallback to localStorage for page refresh recovery + try { + const stored = localStorage.getItem('dtp_drone_registration'); + if (stored) droneReg = JSON.parse(stored); + } catch {} } + + // Resolve the project: router state > session data > API fetch + const projectFromRouter = routerState?.project; + const sessionProjectRef = sessionData.project; + const projectObjectId = typeof sessionProjectRef === 'string' ? sessionProjectRef : sessionProjectRef?._id; + + let projectData: Project | null = projectFromRouter || null; + if (!projectData && projectObjectId) { + projectData = await projectApi.get(projectObjectId); + } + setProject(projectData); + + const turnsData = await chatSessionApi.getTurns(sessionId); + setTurns(turnsData); + + const allProviders = await providerApi.getAll(); + setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name))); + + const providerId = typeof sessionData.provider === 'string' + ? sessionData.provider + : sessionData.provider?._id; + setSelectedProviderId(providerId || ''); + setSelectedModelId(sessionData.selectedModel || ''); + setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); + + // Initialize numCtx: persisted value wins, otherwise model's contextWindow + if (sessionData.numCtx) { + setSessionNumCtx(sessionData.numCtx); + } else { + const provider = allProviders.find(p => p._id === providerId); + const model = provider?.models.find(m => m.id === sessionData.selectedModel); + setSessionNumCtx(model?.contextWindow || 131072); + } + + // --- State: REQUEST_LOCK — Request the drone lock --- + if (droneReg && projectData) { + setStartupState(SessionStartupState.RequestLock); + appContext?.setStatusMessage('Locking drone to session...'); + + const success = await socketClient.requestSessionLock( + droneReg, + projectData, + sessionData, + ); + + if (success) { + // Store drone registration in state and persist to localStorage for refresh recovery + setDroneRegistration(droneReg); + localStorage.setItem('dtp_drone_registration', JSON.stringify(droneReg)); + setStartupState(SessionStartupState.LockAcquired); + setStartupState(SessionStartupState.Ready); + appContext?.setStatusMessage('Session ready'); + } else { + setStartupState(SessionStartupState.LockFailed); + appContext?.setStatusMessage('Failed to lock drone — drone may be busy'); + return; // Don't proceed to ready state + } + } else { + // No drone provided — skip lock step, go directly to Ready + // (This handles the case where the view is loaded directly via URL without a drone, + // e.g., for viewing completed sessions or task runs) + setStartupState(SessionStartupState.Ready); + } + + // Set connection state to connecting - will update on socket connect + setConnectionState('connecting'); + appContext?.setStatusMessage('Connecting...'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session'); setConnectionState('error'); @@ -240,6 +309,41 @@ export default function ChatSessionView() { } }; + const retrySessionLock = async () => { + if (!droneRegistration && !location.state) { + // Try localStorage again + try { + const stored = localStorage.getItem('dtp_drone_registration'); + if (stored) { + const drone = JSON.parse(stored); + setDroneRegistration(drone); + } + } catch {} + } + + const droneReg = droneRegistrationRef.current; + if (!droneReg || !sessionRef.current || !projectRef.current) return; + + setStartupState(SessionStartupState.RequestLock); + appContext?.setStatusMessage('Retrying drone lock...'); + + const success = await socketClient.requestSessionLock( + droneReg, + projectRef.current, + sessionRef.current, + ); + + if (success) { + localStorage.setItem('dtp_drone_registration', JSON.stringify(droneReg)); + setStartupState(SessionStartupState.LockAcquired); + setStartupState(SessionStartupState.Ready); + appContext?.setStatusMessage('Session ready'); + } else { + setStartupState(SessionStartupState.LockFailed); + appContext?.setStatusMessage('Failed to lock drone — drone may be busy'); + } + }; + const handleSessionUpdated = useCallback((updates: Partial) => { setSession(prev => prev ? { ...prev, ...updates } : null); }, []); @@ -767,14 +871,12 @@ export default function ChatSessionView() { if (mode === workspaceMode) return; try { - const droneJson = localStorage.getItem('dtp_drone_registration'); - if (!droneJson) { + if (!droneRegistration) { showToast('No drone registration found'); return; } - const registration = JSON.parse(droneJson); const result = await socketClient.requestWorkspaceMode( - registration, + droneRegistration, project, session, mode, @@ -1069,6 +1171,8 @@ export default function ChatSessionView() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + const sessionLocked = startupState === SessionStartupState.Ready; + const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider; const promptPlaceholder = isEditingProvider ? 'Save or cancel provider changes to continue.' @@ -1078,14 +1182,6 @@ export default function ChatSessionView() { ? 'Please select a model first.' : 'Enter your prompt... (Shift+Enter for new line)'; - if (loading) { - return ( -
-

Loading chat session...

-
- ); - } - if (error && !session) { return (
@@ -1121,6 +1217,47 @@ export default function ChatSessionView() { ); } + // Render lock-failed state + if (startupState === SessionStartupState.LockFailed) { + return ( +
+
+

Failed to Lock Drone

+

+ The drone may be busy with another session or unavailable. +

+
+ + +
+
+
+ ); + } + + // Render loading/requesting-lock states + if (startupState === SessionStartupState.Loading || startupState === SessionStartupState.RequestLock) { + return ( +
+

+ {startupState === SessionStartupState.Loading + ? 'Loading chat session...' + : 'Locking drone to session...'} +

+
+ ); + } + return (
{/* Toast notification */} diff --git a/gadget-code/frontend/src/pages/Home.tsx b/gadget-code/frontend/src/pages/Home.tsx index 80c6787..b9728d2 100644 --- a/gadget-code/frontend/src/pages/Home.tsx +++ b/gadget-code/frontend/src/pages/Home.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { Link, useNavigate } from "react-router-dom"; -import type { User, Project, DroneRegistration, SubProcessStat } from "../lib/api"; -import { projectApi, droneApi } from "../lib/api"; +import type { User, Project, DroneRegistration, ChatSession, SubProcessStat } from "../lib/api"; +import { projectApi, droneApi, chatSessionApi } from "../lib/api"; import { socketClient } from "../lib/socket"; import Clock from "../components/Clock"; import GadgetGrid from "../components/GadgetGrid"; @@ -188,7 +188,9 @@ function DashboardSidebar({ const navigate = useNavigate(); const [projects, setProjects] = useState([]); const [drones, setDrones] = useState([]); + const [recentChats, setRecentChats] = useState([]); const [loading, setLoading] = useState(true); + const [noDroneMessage, setNoDroneMessage] = useState(null); useEffect(() => { loadData(); @@ -196,12 +198,14 @@ function DashboardSidebar({ const loadData = async () => { try { - const [projectsData, dronesData] = await Promise.all([ + const [projectsData, dronesData, recentChatsData] = await Promise.all([ projectApi.getAll(), droneApi.getAll(), + chatSessionApi.getAll(undefined, 10), ]); setProjects(projectsData); setDrones(dronesData); + setRecentChats(recentChatsData); } catch (err) { console.error("Failed to load dashboard data", err); } finally { @@ -209,10 +213,107 @@ function DashboardSidebar({ } }; + const getProjectName = (session: ChatSession): string => { + const projectRef = session.project; + if (typeof projectRef === 'object' && projectRef !== null && 'name' in projectRef) { + return projectRef.name; + } + return 'Unknown Project'; + }; + + const getProjectId = (session: ChatSession): string => { + const projectRef = session.project; + if (typeof projectRef === 'object' && projectRef !== null && '_id' in projectRef) { + return projectRef._id; + } + return typeof projectRef === 'string' ? projectRef : ''; + }; + + const handleOpenChat = (session: ChatSession) => { + if (!selectedDrone) { + setNoDroneMessage('Select a drone first to resume a chat'); + setTimeout(() => setNoDroneMessage(null), 3000); + return; + } + const projectId = getProjectId(session); + navigate(`/projects/${projectId}/chat-session/${session._id}`, { + state: { + drone: selectedDrone, + project: typeof session.project === 'object' ? session.project : undefined, + }, + }); + }; + + const modeBadgeColors: Record = { + plan: 'bg-blue-500/20 text-blue-400', + build: 'bg-brand/20 text-brand', + test: 'bg-green-500/20 text-green-400', + ship: 'bg-purple-500/20 text-purple-400', + dev: 'bg-orange-500/20 text-orange-400', + }; + + const formatRelativeTime = (dateStr: string): string => { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHr = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHr / 24); + + if (diffSec < 60) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + return new Date(dateStr).toLocaleDateString(); + }; + return ( ); } diff --git a/gadget-code/frontend/src/pages/ProjectManager.tsx b/gadget-code/frontend/src/pages/ProjectManager.tsx index 89ceeb0..baa86ce 100644 --- a/gadget-code/frontend/src/pages/ProjectManager.tsx +++ b/gadget-code/frontend/src/pages/ProjectManager.tsx @@ -11,7 +11,6 @@ import { type AiProvider, providerApi, } from "../lib/api"; -import { socketClient } from "../lib/socket"; import gabAiImage from "./assets/gab-ai.jpeg"; interface ProjectManagerProps { @@ -1100,18 +1099,7 @@ function RightSidebar({ }); setShowNewChatModal(false); - // Lock the drone to this session BEFORE navigating - if (selectedDrone) { - const success = await socketClient.requestSessionLock( - selectedDrone, - project, - session, - ); - if (!success) { - console.error("Failed to lock drone session"); - } - } - + // Navigate to ChatSessionView via parent — ChatSessionView handles the drone lock. onOpenChatSession(session._id); await loadData(); // Refresh list } catch (err) { @@ -1581,21 +1569,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) { if (!selectedProject || !selectedDrone) return; try { - const session = await chatSessionApi.get(sessionId); - const success = await socketClient.requestSessionLock( - selectedDrone, - selectedProject, - session, - ); - if (!success) { - console.error("Failed to lock drone session"); - return; - } - localStorage.setItem( - "dtp_drone_registration", - JSON.stringify(selectedDrone), - ); - navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`); + // Navigate to ChatSessionView with drone and project in router state. + // ChatSessionView handles the drone lock request itself. + navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`, { + state: { + drone: selectedDrone, + project: selectedProject, + }, + }); } catch (err) { console.error("Failed to open chat session", err); } diff --git a/gadget-code/src/controllers/api/v1/chat-session.ts b/gadget-code/src/controllers/api/v1/chat-session.ts index 029b3f1..9606d17 100644 --- a/gadget-code/src/controllers/api/v1/chat-session.ts +++ b/gadget-code/src/controllers/api/v1/chat-session.ts @@ -49,12 +49,13 @@ class ChatSessionController extends DtpController { } const projectId = req.query.projectId as string | undefined; + const limit = parseInt(req.query.limit as string, 10) || 0; let sessions; if (projectId) { sessions = await ChatSessionService.getByProject(projectId); } else { - sessions = await ChatSessionService.getByUser(user._id); + sessions = await ChatSessionService.getByUser(user._id, limit); } res.json({ diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts index 4d8b921..cc82740 100644 --- a/gadget-code/src/services/chat-session.ts +++ b/gadget-code/src/services/chat-session.ts @@ -178,16 +178,22 @@ class ChatSessionService extends DtpService { } /** - * Gets all chat sessions for a user. + * Gets chat sessions for a user, sorted by most recently active. + * @param limit Maximum number of sessions to return (0 = no limit) */ - async getByUser(userId: GadgetId): Promise { - const sessions = await ChatSession.find({ user: userId }) + async getByUser(userId: GadgetId, limit: number = 0): Promise { + let query = ChatSession.find({ user: userId }) .populate("user", "-passwordSalt -password") .populate("project") .populate("provider", "+apiKey") - .sort({ createdAt: -1 }) + .sort({ lastMessageAt: -1, createdAt: -1 }) .lean(); + if (limit > 0) { + query = query.limit(limit); + } + + const sessions = await query; return sessions as unknown as IChatSession[]; } diff --git a/gadget-drone/src/services/agent.test.ts b/gadget-drone/src/services/agent.test.ts index 530c961..b0d7d4d 100644 --- a/gadget-drone/src/services/agent.test.ts +++ b/gadget-drone/src/services/agent.test.ts @@ -412,10 +412,20 @@ describe("AgentService", () => { expect(toolNames).toContain("file_write"); expect(toolNames).toContain("file_edit"); - // Build mode should not have Plan-only .gadget tools - expect(toolNames).not.toContain("plan_file_read"); - expect(toolNames).not.toContain("plan_file_write"); - expect(toolNames).not.toContain("plan_file_edit"); - expect(toolNames).not.toContain("plan_list"); + // Build mode also has .gadget plan tools (available in all modes) + expect(toolNames).toContain("plan_file_read"); + expect(toolNames).toContain("plan_file_write"); + expect(toolNames).toContain("plan_file_edit"); + expect(toolNames).toContain("plan_list"); + }); + + it("exposes plan tools in all modes", () => { + for (const mode of [ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop]) { + const toolNames = service.getToolNamesForMode(mode); + expect(toolNames).toContain("plan_file_read"); + expect(toolNames).toContain("plan_file_write"); + expect(toolNames).toContain("plan_file_edit"); + expect(toolNames).toContain("plan_list"); + } }); }); diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index df3af3f..d07d837 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -122,11 +122,11 @@ class AgentService extends GadgetService { this.toolbox.register(new ShellExecTool(this.toolbox), writeModes); this.toolbox.register(new SubprocessTool(this.toolbox), writeModes); - // Plan tools — Gadget's own .gadget directory: only available in Plan mode - this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]); - this.toolbox.register(new PlanFileWriteTool(this.toolbox), [ChatSessionMode.Plan]); - this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]); - this.toolbox.register(new PlanListTool(this.toolbox), [ChatSessionMode.Plan]); + // Plan tools — Gadget's .gadget directory: available in all modes + this.toolbox.register(new PlanFileReadTool(this.toolbox), readOnlyModes); + this.toolbox.register(new PlanFileWriteTool(this.toolbox), readOnlyModes); + this.toolbox.register(new PlanFileEditTool(this.toolbox), readOnlyModes); + this.toolbox.register(new PlanListTool(this.toolbox), readOnlyModes); // Chat tools — subagent spawning: available in all modes const subagentTool = new SubagentTool(this.toolbox); diff --git a/packages/ai-toolbox/src/plan/edit.ts b/packages/ai-toolbox/src/plan/edit.ts index b3a87c4..fb52ed7 100644 --- a/packages/ai-toolbox/src/plan/edit.ts +++ b/packages/ai-toolbox/src/plan/edit.ts @@ -23,7 +23,7 @@ export class PlanFileEditTool extends GadgetTool { function: { name: this.name, description: - "Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and updating plans, todos, and other knowledge. It does not affect project files.", + "Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. Operates exclusively in the .gadget directory for updating plans, todos, and other knowledge. Does not affect project files.", parameters: { type: "object", properties: { diff --git a/packages/ai-toolbox/src/plan/list.ts b/packages/ai-toolbox/src/plan/list.ts index 57d1543..2699010 100644 --- a/packages/ai-toolbox/src/plan/list.ts +++ b/packages/ai-toolbox/src/plan/list.ts @@ -24,7 +24,7 @@ export class PlanListTool extends GadgetTool { function: { name: this.name, description: - "List contents of Gadget's .gadget directory. This Plan-mode tool works in Gadget's own directory (.gadget) for exploring stored plans, todos, and other knowledge. It does not affect project files.", + "List contents of Gadget's .gadget directory. Operates exclusively in the .gadget directory for exploring stored plans, todos, and other knowledge. Does not affect project files.", parameters: { type: "object", properties: { diff --git a/packages/ai-toolbox/src/plan/read.ts b/packages/ai-toolbox/src/plan/read.ts index e9a7a34..974531c 100644 --- a/packages/ai-toolbox/src/plan/read.ts +++ b/packages/ai-toolbox/src/plan/read.ts @@ -28,7 +28,7 @@ export class PlanFileReadTool extends GadgetTool { function: { name: this.name, description: - "Read a file from Gadget's .gadget directory with line numbers. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and reading plans, todos, and other knowledge. It does not affect project files.", + "Read a file from Gadget's .gadget directory with line numbers. Operates exclusively in the .gadget directory for storing and reading plans, todos, and other knowledge. Does not affect project files.", parameters: { type: "object", properties: { diff --git a/packages/ai-toolbox/src/plan/write.ts b/packages/ai-toolbox/src/plan/write.ts index f935c4c..012efe7 100644 --- a/packages/ai-toolbox/src/plan/write.ts +++ b/packages/ai-toolbox/src/plan/write.ts @@ -24,7 +24,7 @@ export class PlanFileWriteTool extends GadgetTool { function: { name: this.name, description: - "Create or overwrite a file in Gadget's .gadget directory. The .gadget directory is created automatically if it doesn't exist. This Plan-mode tool works in Gadget's own directory (.gadget) for storing plans, todos, and other knowledge. It does not affect project files.", + "Create or overwrite a file in Gadget's .gadget directory. The .gadget directory is created automatically if it doesn't exist. Operates exclusively in the .gadget directory for storing plans, todos, and other knowledge. Does not affect project files.", parameters: { type: "object", properties: {