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.
This commit is contained in:
Rob Colbert 2026-05-17 14:09:24 -04:00
parent ec774cfe99
commit c90e8ce0e8
19 changed files with 382 additions and 115 deletions

View File

@ -114,3 +114,9 @@ If code is needed by both consumer packages, it belongs in the appropriate share
- MongoDB on `localhost:27017` - MongoDB on `localhost:27017`
- Redis on `localhost:6379` - Redis on `localhost:6379`
- SSL certificates in `gadget-code/ssl/` directory (for dev servers) - 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.

View File

@ -38,6 +38,10 @@ You must remain within the project directory, which is the current working direc
{{process_management_block}} {{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 ## 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: 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:

View File

@ -34,6 +34,10 @@ NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT
{{process_management_block}} {{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 ## 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. 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.

View File

@ -40,6 +40,21 @@ Don't announce tool usage, just execute and use findings to inform your planning
{{tool_block}} {{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}} {{process_management_block}}
## INSTRUCTIONS ## INSTRUCTIONS

View File

@ -24,6 +24,10 @@ Use your tools decisively. Run tests, check builds, verify deployments, and exec
{{process_management_block}} {{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 ## INSTRUCTIONS
When the user sends you a prompt: When the user sends you a prompt:

View File

@ -24,6 +24,10 @@ Use your tools aggressively for testing. Run tests frequently, read test files,
{{process_management_block}} {{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 ## INSTRUCTIONS
When the user sends you a prompt: When the user sends you a prompt:

View File

@ -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 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 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 ### 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). 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).

View File

@ -469,9 +469,9 @@ export interface ChatTurn {
} }
export const chatSessionApi = { export const chatSessionApi = {
getAll: (projectId?: string) => getAll: (projectId?: string, limit?: number) =>
api.get<ChatSession[]>( api.get<ChatSession[]>(
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}`, `/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`,
), ),
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`), get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
create: (data: { create: (data: {

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useContext, useCallback } from 'react'; 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 { socketClient } from '../lib/socket';
import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api'; import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api';
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from '../lib/types';
@ -44,9 +44,23 @@ interface SubagentStreamState {
stats?: ChatTurnStats; 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() { export default function ChatSessionView() {
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>(); const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const appContext = useContext(AppContext); const appContext = useContext(AppContext);
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
@ -57,7 +71,8 @@ export default function ChatSessionView() {
const [isAborting, setIsAborting] = useState(false); const [isAborting, setIsAborting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sessionLocked, setSessionLocked] = useState(true); const [startupState, setStartupState] = useState<SessionStartupState>(SessionStartupState.Loading);
const [droneRegistration, setDroneRegistration] = useState<any>(null);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
const [isUpdatingMode, setIsUpdatingMode] = useState(false); const [isUpdatingMode, setIsUpdatingMode] = useState(false);
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
@ -95,6 +110,7 @@ export default function ChatSessionView() {
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map()); const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
const sessionRef = useRef<ChatSession | null>(null); const sessionRef = useRef<ChatSession | null>(null);
const projectRef = useRef<Project | null>(null); const projectRef = useRef<Project | null>(null);
const droneRegistrationRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
loadSessionData(); loadSessionData();
@ -129,15 +145,19 @@ export default function ChatSessionView() {
projectRef.current = project; projectRef.current = project;
}, [project]); }, [project]);
// Start heartbeat when session+project are loaded
useEffect(() => { useEffect(() => {
if (session && project) { droneRegistrationRef.current = droneRegistration;
}, [droneRegistration]);
// Start heartbeat when startup is complete (Ready state)
useEffect(() => {
if (startupState === SessionStartupState.Ready) {
socketClient.startSessionHeartbeat(); socketClient.startSessionHeartbeat();
} }
return () => { return () => {
socketClient.stopSessionHeartbeat(); socketClient.stopSessionHeartbeat();
}; };
}, [session, project]); }, [startupState]);
// Handle socket reconnection and tab lock // Handle socket reconnection and tab lock
const handleSocketConnect = useCallback(() => { const handleSocketConnect = useCallback(() => {
@ -176,12 +196,10 @@ export default function ChatSessionView() {
// Release session lock on unmount only // Release session lock on unmount only
useEffect(() => { useEffect(() => {
return () => { return () => {
const droneJson = localStorage.getItem('dtp_drone_registration'); if (droneRegistrationRef.current && sessionRef.current && projectRef.current) {
if (droneJson && sessionRef.current && projectRef.current) {
try { try {
const registration = JSON.parse(droneJson);
socketClient.releaseSessionLock( socketClient.releaseSessionLock(
registration, droneRegistrationRef.current,
projectRef.current, projectRef.current,
sessionRef.current, sessionRef.current,
); );
@ -193,45 +211,96 @@ export default function ChatSessionView() {
}, []); }, []);
const loadSessionData = async () => { const loadSessionData = async () => {
setStartupState(SessionStartupState.Loading);
setLoading(true);
setError('');
try { try {
if (sessionId) { if (!sessionId) return;
const sessionData = await chatSessionApi.get(sessionId);
setSession(sessionData);
const projectRef = sessionData.project; // --- State: LOADING — Fetch session, project, turns, providers ---
const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id;
if (projectObjectId) {
const projectData = await projectApi.get(projectObjectId);
setProject(projectData);
}
const turnsData = await chatSessionApi.getTurns(sessionId); const sessionData = await chatSessionApi.get(sessionId);
setTurns(turnsData); setSession(sessionData);
setSessionLocked(true);
const allProviders = await providerApi.getAll(); // Resolve the drone registration: router state > localStorage fallback
setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name))); const routerState = location.state as RouterState | null;
const droneFromRouter = routerState?.drone;
const providerId = typeof sessionData.provider === 'string' let droneReg = droneFromRouter || null;
? sessionData.provider if (!droneReg) {
: sessionData.provider?._id; // Fallback to localStorage for page refresh recovery
setSelectedProviderId(providerId || ''); try {
setSelectedModelId(sessionData.selectedModel || ''); const stored = localStorage.getItem('dtp_drone_registration');
setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); if (stored) droneReg = JSON.parse(stored);
} catch {}
// 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 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load session'); setError(err instanceof Error ? err.message : 'Failed to load session');
setConnectionState('error'); 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<ChatSession>) => { const handleSessionUpdated = useCallback((updates: Partial<ChatSession>) => {
setSession(prev => prev ? { ...prev, ...updates } : null); setSession(prev => prev ? { ...prev, ...updates } : null);
}, []); }, []);
@ -767,14 +871,12 @@ export default function ChatSessionView() {
if (mode === workspaceMode) return; if (mode === workspaceMode) return;
try { try {
const droneJson = localStorage.getItem('dtp_drone_registration'); if (!droneRegistration) {
if (!droneJson) {
showToast('No drone registration found'); showToast('No drone registration found');
return; return;
} }
const registration = JSON.parse(droneJson);
const result = await socketClient.requestWorkspaceMode( const result = await socketClient.requestWorkspaceMode(
registration, droneRegistration,
project, project,
session, session,
mode, mode,
@ -1069,6 +1171,8 @@ export default function ChatSessionView() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}; };
const sessionLocked = startupState === SessionStartupState.Ready;
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider; const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
const promptPlaceholder = isEditingProvider const promptPlaceholder = isEditingProvider
? 'Save or cancel provider changes to continue.' ? 'Save or cancel provider changes to continue.'
@ -1078,14 +1182,6 @@ export default function ChatSessionView() {
? 'Please select a model first.' ? 'Please select a model first.'
: 'Enter your prompt... (Shift+Enter for new line)'; : 'Enter your prompt... (Shift+Enter for new line)';
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">Loading chat session...</p>
</div>
);
}
if (error && !session) { if (error && !session) {
return ( return (
<div className="flex-1 flex items-center justify-center bg-bg-primary"> <div className="flex-1 flex items-center justify-center bg-bg-primary">
@ -1121,6 +1217,47 @@ export default function ChatSessionView() {
); );
} }
// Render lock-failed state
if (startupState === SessionStartupState.LockFailed) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<div className="text-center">
<p className="text-red-500 mb-2">Failed to Lock Drone</p>
<p className="text-text-secondary mb-4 text-sm">
The drone may be busy with another session or unavailable.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={retrySessionLock}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
>
Retry Lock
</button>
<button
onClick={() => navigate(`/projects/${projectId}`)}
className="px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
Back to Project
</button>
</div>
</div>
</div>
);
}
// Render loading/requesting-lock states
if (startupState === SessionStartupState.Loading || startupState === SessionStartupState.RequestLock) {
return (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">
{startupState === SessionStartupState.Loading
? 'Loading chat session...'
: 'Locking drone to session...'}
</p>
</div>
);
}
return ( return (
<div className="flex-1 flex bg-bg-primary overflow-hidden relative"> <div className="flex-1 flex bg-bg-primary overflow-hidden relative">
{/* Toast notification */} {/* Toast notification */}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import type { User, Project, DroneRegistration, SubProcessStat } from "../lib/api"; import type { User, Project, DroneRegistration, ChatSession, SubProcessStat } from "../lib/api";
import { projectApi, droneApi } from "../lib/api"; import { projectApi, droneApi, chatSessionApi } from "../lib/api";
import { socketClient } from "../lib/socket"; import { socketClient } from "../lib/socket";
import Clock from "../components/Clock"; import Clock from "../components/Clock";
import GadgetGrid from "../components/GadgetGrid"; import GadgetGrid from "../components/GadgetGrid";
@ -188,7 +188,9 @@ function DashboardSidebar({
const navigate = useNavigate(); const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [drones, setDrones] = useState<DroneRegistration[]>([]); const [drones, setDrones] = useState<DroneRegistration[]>([]);
const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [noDroneMessage, setNoDroneMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -196,12 +198,14 @@ function DashboardSidebar({
const loadData = async () => { const loadData = async () => {
try { try {
const [projectsData, dronesData] = await Promise.all([ const [projectsData, dronesData, recentChatsData] = await Promise.all([
projectApi.getAll(), projectApi.getAll(),
droneApi.getAll(), droneApi.getAll(),
chatSessionApi.getAll(undefined, 10),
]); ]);
setProjects(projectsData); setProjects(projectsData);
setDrones(dronesData); setDrones(dronesData);
setRecentChats(recentChatsData);
} catch (err) { } catch (err) {
console.error("Failed to load dashboard data", err); console.error("Failed to load dashboard data", err);
} finally { } 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<string, string> = {
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 ( return (
<aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto"> <aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto">
<Clock /> <Clock />
<div className="p-3 border-t border-border-subtle">
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Recent Chats
</div>
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : recentChats.length === 0 ? (
<p className="text-sm text-text-muted px-2">No recent chats.</p>
) : (
<div className="space-y-1">
{recentChats.map((session) => (
<button
key={session._id}
onClick={() => handleOpenChat(session)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-medium text-xs">{session.name || 'Untitled'}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${modeBadgeColors[session.mode] || 'bg-gray-500/20 text-gray-400'}`}>
{session.mode}
</span>
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-[11px] text-text-muted truncate">{getProjectName(session)}</span>
<span className="text-[10px] text-text-muted shrink-0">
{formatRelativeTime(session.lastMessageAt || session.createdAt)}
</span>
</div>
</button>
))}
</div>
)}
{noDroneMessage && (
<p className="text-xs text-yellow-400 mt-2 px-2">{noDroneMessage}</p>
)}
{selectedDrone && (
<p className="text-[10px] text-text-muted mt-2 px-2 italic">
Select a chat to resume with {selectedDrone.hostname}
</p>
)}
</div>
<div className="p-3 border-t border-border-subtle"> <div className="p-3 border-t border-border-subtle">
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2"> <div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
<span>Projects</span> <span>Projects</span>
@ -318,14 +419,6 @@ function DashboardSidebar({
)} )}
</div> </div>
<div className="p-3 border-t border-border-subtle">
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Recent Chats
</div>
<div className="space-y-1">
<p className="text-sm text-text-muted px-2">Loading...</p>
</div>
</div>
</aside> </aside>
); );
} }

View File

@ -11,7 +11,6 @@ import {
type AiProvider, type AiProvider,
providerApi, providerApi,
} from "../lib/api"; } from "../lib/api";
import { socketClient } from "../lib/socket";
import gabAiImage from "./assets/gab-ai.jpeg"; import gabAiImage from "./assets/gab-ai.jpeg";
interface ProjectManagerProps { interface ProjectManagerProps {
@ -1100,18 +1099,7 @@ function RightSidebar({
}); });
setShowNewChatModal(false); setShowNewChatModal(false);
// Lock the drone to this session BEFORE navigating // Navigate to ChatSessionView via parent — ChatSessionView handles the drone lock.
if (selectedDrone) {
const success = await socketClient.requestSessionLock(
selectedDrone,
project,
session,
);
if (!success) {
console.error("Failed to lock drone session");
}
}
onOpenChatSession(session._id); onOpenChatSession(session._id);
await loadData(); // Refresh list await loadData(); // Refresh list
} catch (err) { } catch (err) {
@ -1581,21 +1569,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
if (!selectedProject || !selectedDrone) return; if (!selectedProject || !selectedDrone) return;
try { try {
const session = await chatSessionApi.get(sessionId); // Navigate to ChatSessionView with drone and project in router state.
const success = await socketClient.requestSessionLock( // ChatSessionView handles the drone lock request itself.
selectedDrone, navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`, {
selectedProject, state: {
session, drone: selectedDrone,
); project: selectedProject,
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}`);
} catch (err) { } catch (err) {
console.error("Failed to open chat session", err); console.error("Failed to open chat session", err);
} }

View File

@ -49,12 +49,13 @@ class ChatSessionController extends DtpController {
} }
const projectId = req.query.projectId as string | undefined; const projectId = req.query.projectId as string | undefined;
const limit = parseInt(req.query.limit as string, 10) || 0;
let sessions; let sessions;
if (projectId) { if (projectId) {
sessions = await ChatSessionService.getByProject(projectId); sessions = await ChatSessionService.getByProject(projectId);
} else { } else {
sessions = await ChatSessionService.getByUser(user._id); sessions = await ChatSessionService.getByUser(user._id, limit);
} }
res.json({ res.json({

View File

@ -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<IChatSession[]> { async getByUser(userId: GadgetId, limit: number = 0): Promise<IChatSession[]> {
const sessions = await ChatSession.find({ user: userId }) let query = ChatSession.find({ user: userId })
.populate("user", "-passwordSalt -password") .populate("user", "-passwordSalt -password")
.populate("project") .populate("project")
.populate("provider", "+apiKey") .populate("provider", "+apiKey")
.sort({ createdAt: -1 }) .sort({ lastMessageAt: -1, createdAt: -1 })
.lean(); .lean();
if (limit > 0) {
query = query.limit(limit);
}
const sessions = await query;
return sessions as unknown as IChatSession[]; return sessions as unknown as IChatSession[];
} }

View File

@ -412,10 +412,20 @@ describe("AgentService", () => {
expect(toolNames).toContain("file_write"); expect(toolNames).toContain("file_write");
expect(toolNames).toContain("file_edit"); expect(toolNames).toContain("file_edit");
// Build mode should not have Plan-only .gadget tools // Build mode also has .gadget plan tools (available in all modes)
expect(toolNames).not.toContain("plan_file_read"); expect(toolNames).toContain("plan_file_read");
expect(toolNames).not.toContain("plan_file_write"); expect(toolNames).toContain("plan_file_write");
expect(toolNames).not.toContain("plan_file_edit"); expect(toolNames).toContain("plan_file_edit");
expect(toolNames).not.toContain("plan_list"); 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");
}
}); });
}); });

View File

@ -122,11 +122,11 @@ class AgentService extends GadgetService {
this.toolbox.register(new ShellExecTool(this.toolbox), writeModes); this.toolbox.register(new ShellExecTool(this.toolbox), writeModes);
this.toolbox.register(new SubprocessTool(this.toolbox), writeModes); this.toolbox.register(new SubprocessTool(this.toolbox), writeModes);
// Plan tools — Gadget's own .gadget directory: only available in Plan mode // Plan tools — Gadget's .gadget directory: available in all modes
this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanFileReadTool(this.toolbox), readOnlyModes);
this.toolbox.register(new PlanFileWriteTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanFileWriteTool(this.toolbox), readOnlyModes);
this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanFileEditTool(this.toolbox), readOnlyModes);
this.toolbox.register(new PlanListTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanListTool(this.toolbox), readOnlyModes);
// Chat tools — subagent spawning: available in all modes // Chat tools — subagent spawning: available in all modes
const subagentTool = new SubagentTool(this.toolbox); const subagentTool = new SubagentTool(this.toolbox);

View File

@ -23,7 +23,7 @@ export class PlanFileEditTool extends GadgetTool {
function: { function: {
name: this.name, name: this.name,
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {

View File

@ -24,7 +24,7 @@ export class PlanListTool extends GadgetTool {
function: { function: {
name: this.name, name: this.name,
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {

View File

@ -28,7 +28,7 @@ export class PlanFileReadTool extends GadgetTool {
function: { function: {
name: this.name, name: this.name,
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {

View File

@ -24,7 +24,7 @@ export class PlanFileWriteTool extends GadgetTool {
function: { function: {
name: this.name, name: this.name,
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {