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:
parent
ec774cfe99
commit
c90e8ce0e8
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -469,9 +469,9 @@ export interface ChatTurn {
|
||||
}
|
||||
|
||||
export const chatSessionApi = {
|
||||
getAll: (projectId?: string) =>
|
||||
getAll: (projectId?: string, limit?: number) =>
|
||||
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}`),
|
||||
create: (data: {
|
||||
|
||||
@ -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<Project | null>(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>(SessionStartupState.Loading);
|
||||
const [droneRegistration, setDroneRegistration] = useState<any>(null);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
|
||||
const [isUpdatingMode, setIsUpdatingMode] = useState(false);
|
||||
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
|
||||
@ -95,6 +110,7 @@ export default function ChatSessionView() {
|
||||
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
||||
const sessionRef = useRef<ChatSession | null>(null);
|
||||
const projectRef = useRef<Project | null>(null);
|
||||
const droneRegistrationRef = useRef<any>(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<ChatSession>) => {
|
||||
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 (
|
||||
<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) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden relative">
|
||||
{/* Toast notification */}
|
||||
|
||||
@ -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<Project[]>([]);
|
||||
const [drones, setDrones] = useState<DroneRegistration[]>([]);
|
||||
const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noDroneMessage, setNoDroneMessage] = useState<string | null>(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<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 (
|
||||
<aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto">
|
||||
<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="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
<span>Projects</span>
|
||||
@ -318,14 +419,6 @@ function DashboardSidebar({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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[]> {
|
||||
const sessions = await ChatSession.find({ user: userId })
|
||||
async getByUser(userId: GadgetId, limit: number = 0): Promise<IChatSession[]> {
|
||||
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[];
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user