@aion0/forge 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +128 -5
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +545 -103
- package/lib/claude-sessions.ts +26 -28
- package/lib/forge-mcp-server.ts +389 -0
- package/lib/forge-skills/forge-inbox.md +13 -12
- package/lib/forge-skills/forge-send.md +13 -6
- package/lib/forge-skills/forge-status.md +12 -12
- package/lib/project-sessions.ts +48 -0
- package/lib/session-utils.ts +49 -0
- package/lib/workspace/__tests__/state-machine.test.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -5
- package/lib/workspace/backends/cli-backend.ts +3 -0
- package/lib/workspace/orchestrator.ts +740 -88
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/package.json +4 -2
package/.forge/mcp.json
ADDED
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,134 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.8
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.7
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- feat: Restart Session button for persistentSession agents
|
|
9
|
+
- feat: boundSessionId drives non-primary terminal open flow
|
|
10
|
+
- feat: boundSessionId per agent — each agent has its own last session
|
|
11
|
+
- feat: execution mode determined by config, not runtime tmux state
|
|
12
|
+
- feat: /resolve API for workspace lookup + skills use env var → API fallback
|
|
13
|
+
- feat: switch session button (▾) for non-primary agents with active terminal
|
|
14
|
+
- feat: auto-fallback to headless when terminal startup fails
|
|
15
|
+
- feat: Forge agent monitors all inbox states
|
|
16
|
+
- feat: Forge agent — autonomous bus monitor with periodic scanning
|
|
17
|
+
- feat: Forge agent auto-requests summary when agent completes without reply
|
|
18
|
+
- feat: nudge sender when target agent completes — inject hint into terminal
|
|
19
|
+
- feat: restore auto-reply on completion + keep check_outbox as supplement
|
|
20
|
+
- feat: auto-reply to message sender on completion + check_outbox tool
|
|
21
|
+
- feat: add MCP Server to monitor panel and status script
|
|
22
|
+
- feat: get_agents tool + fuzzy send_message matching
|
|
23
|
+
- feat: inject FORGE env vars at all terminal launch points
|
|
24
|
+
- feat: --mcp-config injected at all claude terminal launch points
|
|
25
|
+
- feat: Forge MCP Server — replace HTTP skills with native tool calls
|
|
26
|
+
- feat: open terminal button on session list items
|
|
27
|
+
- feat: detect CLI startup failures in persistent sessions
|
|
28
|
+
- feat: all terminal entry points use fixedSessionId via --resume
|
|
29
|
+
- feat: bind button on sessions list to set fixedSessionId
|
|
30
|
+
- feat: click-to-change session binding in project header
|
|
31
|
+
- feat: show bound session in project header and session list
|
|
32
|
+
- feat: session picker dropdown for fixedSessionId binding
|
|
33
|
+
- feat: session list shows full ID on expand with copy button
|
|
34
|
+
- feat: show and edit fixedSessionId in agent config modal
|
|
35
|
+
- feat: primary agent setup prompt for new and existing workspaces
|
|
36
|
+
- feat: primary agent checkbox in agent config modal
|
|
37
|
+
- feat: terminal dock — inline right panel with tabs and resizable width
|
|
38
|
+
- feat: VibeCoding uses workspace primary agent's fixed session
|
|
39
|
+
- feat: fixed CLI session binding for primary agent
|
|
40
|
+
- feat: primary agent — terminal-only, fixed session, root directory
|
|
41
|
+
- feat: persistent terminal session per agent
|
|
42
|
+
- feat: watch send_message auto-detects tmux sessions (workspace + VibeCoding)
|
|
43
|
+
- feat: watch send_message injects directly into terminal session
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
- fix: unset profile env vars before setting new ones on agent switch
|
|
47
|
+
- fix: normalize workDir paths before encoding session directory
|
|
48
|
+
- fix: session list uses agent's workDir, not project root
|
|
49
|
+
- fix: clear boundSessionId on agent change even without tmux session
|
|
50
|
+
- fix: kill terminal and clear boundSessionId when agent CLI changes
|
|
51
|
+
- fix: rename "Keep terminal session alive" to "Terminal mode"
|
|
52
|
+
- fix: codex skipPermissionsFlag back to --full-auto
|
|
53
|
+
- fix: codex skip flag + -c only when sessions exist
|
|
54
|
+
- fix: set FORGE env vars as separate tmux command before CLI start
|
|
55
|
+
- fix: set FORGE env vars in tmux, skills use env vars not curl lookup
|
|
56
|
+
- fix: mark forge skills as FALLBACK so MCP tools take priority
|
|
57
|
+
- fix: nudge explicitly says use MCP tool, not forge-send skill
|
|
58
|
+
- fix: skills prefer MCP tools, fix subdirectory workspace lookup
|
|
59
|
+
- fix: show headless after Kill, terminal (pending) only before daemon start
|
|
60
|
+
- fix: Suspend vs Kill terminal behavior
|
|
61
|
+
- fix: Forge agent nudge uses stronger language to trigger send_message
|
|
62
|
+
- fix: always resolve agent launch info before opening terminal
|
|
63
|
+
- fix: bus log updates immediately after message delete (no refresh needed)
|
|
64
|
+
- fix: stop daemon kills tmux sessions + Forge agent only scans new messages
|
|
65
|
+
- fix: enlarge session switch button (▾) for easier clicking
|
|
66
|
+
- fix: terminal button visible during running state (was hidden)
|
|
67
|
+
- fix: daemon persistent session uses -c for non-primary agents
|
|
68
|
+
- fix: restore -c flag for non-primary agents to resume latest session
|
|
69
|
+
- fix: Forge agent processes historical pending/running, skips done/failed
|
|
70
|
+
- fix: Forge agent only scans messages after daemon start, ignores history
|
|
71
|
+
- fix: Forge agent restarts message loop for stuck pending messages
|
|
72
|
+
- fix: add skipPermissions to FloatingTerminalInline (was missing)
|
|
73
|
+
- fix: pass skipPermissions to FloatingTerminal, add --dangerously-skip-permissions
|
|
74
|
+
- fix: block terminal open when daemon not started
|
|
75
|
+
- fix: re-open terminal when agent config changes (close old first)
|
|
76
|
+
- fix: don't use claude -c for workspace agents (subdirs may have no session)
|
|
77
|
+
- fix: MCP monitor shows port not pid, add separator after Tunnel
|
|
78
|
+
- fix: add type:'sse' to MCP config (required by Claude Code schema)
|
|
79
|
+
- fix: MCP server port 7830 → 8406 (follows 8403/8404/8405 sequence)
|
|
80
|
+
- fix: MCP agentId resolved by server, not hardcoded in all URLs
|
|
81
|
+
- fix: MAX_ACTIVE limits daemon start, not workspace loading
|
|
82
|
+
- fix: increase MAX_ACTIVE workspaces from 2 to 5
|
|
83
|
+
- fix: move all session action buttons to second row (right-aligned)
|
|
84
|
+
- fix: skip message loop when persistent session fails to start
|
|
85
|
+
- fix: only primary agent uses fixedSession, others use -c or session dialog
|
|
86
|
+
- fix: refresh fixed session display after bind via window event
|
|
87
|
+
- fix: resolveFixedSession auto-binds latest session when not set
|
|
88
|
+
- fix: add error logging to session bind button for debugging
|
|
89
|
+
- fix: session list uses disk files as source of truth, not stale index
|
|
90
|
+
- fix: bind button always visible, set session when none bound
|
|
91
|
+
- fix: limit fixedSessionId auto-bind to only 3 entry points
|
|
92
|
+
- fix: show bind button even when no session is bound yet
|
|
93
|
+
- fix: auto-bind fixedSessionId at Next.js API layer
|
|
94
|
+
- fix: ensure primary session binding at all entry points
|
|
95
|
+
- fix: auto-bind fixedSessionId on agent add and config update
|
|
96
|
+
- fix: show terminal (pending) for persistentSession agents without tmux yet
|
|
97
|
+
- fix: replace nested button with div+span in terminal dock tabs
|
|
98
|
+
- fix: don't auto-open terminal UI on workspace load
|
|
99
|
+
- fix: session binding edge cases
|
|
100
|
+
- fix: auto-bind latest CLI session on upgrade instead of creating new
|
|
101
|
+
- fix: persistent session starts claude with -c (resume last session)
|
|
102
|
+
- fix: verify stored tmuxSession is alive before using, fallback to find
|
|
103
|
+
- fix: findTmuxSession reads terminal-state.json for VibeCoding sessions
|
|
104
|
+
- fix: session watch detects file switch (new session created)
|
|
105
|
+
- fix: getAllAgentStates preserves tmuxSession + currentMessageId
|
|
106
|
+
- fix: persistent session emits state update so frontend knows tmuxSession
|
|
107
|
+
- fix: watch send_message sends configured prompt only + auto-Enter
|
|
108
|
+
- fix: open terminal attaches to existing tmux session regardless of mode
|
|
109
|
+
- fix: session watch only sends last matching entry, not all content
|
|
110
|
+
- fix: remove .js extension from dynamic import for Next.js compat
|
|
111
|
+
- fix: workspace terminal defaults to resume last session (-c)
|
|
112
|
+
- fix: updateAgentConfig rebuilds worker + emits status update
|
|
113
|
+
- fix: CLI backend auto-finds latest session when no sessionId
|
|
114
|
+
|
|
115
|
+
### Performance
|
|
116
|
+
- perf: cache session binding check, defer SessionView loading
|
|
117
|
+
|
|
118
|
+
### Refactoring
|
|
119
|
+
- refactor: MCP context via URL params, remove FORGE env var injection
|
|
120
|
+
- refactor: fixedSessionId is project-level, not workspace/agent-level
|
|
121
|
+
- refactor: remove manual/auto mode, unify message execution via tmuxSession
|
|
122
|
+
|
|
123
|
+
### Documentation
|
|
124
|
+
- revert auto-resume + document persistent terminal session plan
|
|
6
125
|
|
|
7
126
|
### Other
|
|
8
|
-
-
|
|
127
|
+
- revert: remove orchestrator auto-reply, let agent decide via MCP
|
|
128
|
+
- cleanup: remove fixedSessionId from agent config and workspace state
|
|
129
|
+
- simplify: remove auto-detect session binding complexity
|
|
130
|
+
- revert: restore floating terminal windows, remove dock panel
|
|
131
|
+
- revert auto-resume + document persistent terminal session plan
|
|
9
132
|
|
|
10
133
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.
|
|
134
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.7...v0.5.8
|
package/app/api/monitor/route.ts
CHANGED
|
@@ -24,6 +24,17 @@ export async function GET() {
|
|
|
24
24
|
const workspace = countProcess('workspace-standalone');
|
|
25
25
|
const tunnel = countProcess('cloudflared tunnel');
|
|
26
26
|
|
|
27
|
+
// MCP Server (runs inside workspace process, check /health endpoint)
|
|
28
|
+
let mcpStatus = { running: false, sessions: 0 };
|
|
29
|
+
try {
|
|
30
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
31
|
+
const mcpRes = run(`curl -s http://localhost:${mcpPort}/health 2>/dev/null`);
|
|
32
|
+
if (mcpRes.includes('"ok":true')) {
|
|
33
|
+
const data = JSON.parse(mcpRes);
|
|
34
|
+
mcpStatus = { running: true, sessions: data.sessions || 0 };
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
27
38
|
// Tunnel URL
|
|
28
39
|
let tunnelUrl = '';
|
|
29
40
|
try {
|
|
@@ -55,6 +66,7 @@ export async function GET() {
|
|
|
55
66
|
telegram: { running: telegram.count > 0, pid: telegram.pid, startedAt: telegram.startedAt },
|
|
56
67
|
workspace: { running: workspace.count > 0, pid: workspace.pid, startedAt: workspace.startedAt },
|
|
57
68
|
tunnel: { running: tunnel.count > 0, pid: tunnel.pid, url: tunnelUrl, startedAt: tunnel.startedAt },
|
|
69
|
+
mcp: { running: mcpStatus.running, port: 8406, sessions: mcpStatus.sessions },
|
|
58
70
|
},
|
|
59
71
|
sessions,
|
|
60
72
|
uptime: uptime.replace(/.*up\s+/, '').replace(/,\s+\d+ user.*/, '').trim(),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getFixedSession, setFixedSession, clearFixedSession, getAllFixedSessions } from '@/lib/project-sessions';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// GET: get fixed session for a project, or all bindings
|
|
7
|
+
export async function GET(req: Request) {
|
|
8
|
+
const url = new URL(req.url);
|
|
9
|
+
const projectPath = url.searchParams.get('projectPath');
|
|
10
|
+
if (projectPath) {
|
|
11
|
+
// Also ensure mcp.json exists when querying
|
|
12
|
+
ensureMcpConfig(projectPath);
|
|
13
|
+
return NextResponse.json({ projectPath, fixedSessionId: getFixedSession(projectPath) || null });
|
|
14
|
+
}
|
|
15
|
+
return NextResponse.json(getAllFixedSessions());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// POST: set fixed session or ensure MCP config
|
|
19
|
+
export async function POST(req: Request) {
|
|
20
|
+
const body = await req.json();
|
|
21
|
+
const { projectPath, fixedSessionId, action } = body;
|
|
22
|
+
if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
|
|
23
|
+
|
|
24
|
+
// Ensure MCP config action
|
|
25
|
+
if (action === 'ensure_mcp') {
|
|
26
|
+
ensureMcpConfig(projectPath);
|
|
27
|
+
return NextResponse.json({ ok: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!fixedSessionId) {
|
|
31
|
+
clearFixedSession(projectPath);
|
|
32
|
+
return NextResponse.json({ ok: true, cleared: true });
|
|
33
|
+
}
|
|
34
|
+
setFixedSession(projectPath, fixedSessionId);
|
|
35
|
+
return NextResponse.json({ ok: true, projectPath, fixedSessionId });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Generate .forge/mcp.json in the project directory with workspace context baked in */
|
|
39
|
+
function ensureMcpConfig(projectPath: string): void {
|
|
40
|
+
try {
|
|
41
|
+
const forgeDir = join(projectPath, '.forge');
|
|
42
|
+
const configPath = join(forgeDir, 'mcp.json');
|
|
43
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
44
|
+
|
|
45
|
+
// Resolve workspace + primary agent for this project
|
|
46
|
+
let wsParam = '';
|
|
47
|
+
try {
|
|
48
|
+
const { findWorkspaceByProject } = require('@/lib/workspace');
|
|
49
|
+
const ws = findWorkspaceByProject(projectPath);
|
|
50
|
+
if (ws) {
|
|
51
|
+
wsParam = `?workspaceId=${ws.id}`;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
|
|
55
|
+
const config = { mcpServers: { forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse${wsParam}` } } };
|
|
56
|
+
|
|
57
|
+
// Always rewrite (workspace context may have changed)
|
|
58
|
+
mkdirSync(forgeDir, { recursive: true });
|
|
59
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
@@ -71,7 +71,7 @@ export async function POST(req: Request) {
|
|
|
71
71
|
dependsOn: agent.dependsOn.map((d: string) => idMap.get(d) || d),
|
|
72
72
|
entries: agent.type === 'input' ? [] : undefined,
|
|
73
73
|
});
|
|
74
|
-
state.agentStates[idMap.get(agent.id) || agent.id] = { smithStatus: 'down',
|
|
74
|
+
state.agentStates[idMap.get(agent.id) || agent.id] = { smithStatus: 'down', taskStatus: 'idle', history: [], artifacts: [] };
|
|
75
75
|
}
|
|
76
76
|
if (template.nodePositions) {
|
|
77
77
|
for (const [oldId, pos] of Object.entries(template.nodePositions)) {
|
package/check-forge-status.sh
CHANGED
|
@@ -41,6 +41,15 @@ else
|
|
|
41
41
|
echo " ○ Workspace stopped"
|
|
42
42
|
fi
|
|
43
43
|
|
|
44
|
+
# MCP Server (runs inside workspace-standalone)
|
|
45
|
+
mcp_status=$(curl -s http://localhost:8406/health 2>/dev/null)
|
|
46
|
+
if echo "$mcp_status" | grep -q '"ok":true'; then
|
|
47
|
+
sessions=$(echo "$mcp_status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sessions',0))" 2>/dev/null)
|
|
48
|
+
echo " ● MCP Server running (port: 8406, sessions: $sessions)"
|
|
49
|
+
else
|
|
50
|
+
echo " ○ MCP Server stopped"
|
|
51
|
+
fi
|
|
52
|
+
|
|
44
53
|
# Cloudflare Tunnel
|
|
45
54
|
count=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | wc -l | tr -d ' ')
|
|
46
55
|
pid=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | awk '{print $2}' | head -1)
|
|
@@ -9,6 +9,7 @@ interface MonitorData {
|
|
|
9
9
|
telegram: { running: boolean; pid: string; startedAt?: string };
|
|
10
10
|
workspace: { running: boolean; pid: string; startedAt?: string };
|
|
11
11
|
tunnel: { running: boolean; pid: string; url: string };
|
|
12
|
+
mcp: { running: boolean; port: number; sessions: number };
|
|
12
13
|
};
|
|
13
14
|
sessions: { name: string; created: string; attached: boolean; windows: number }[];
|
|
14
15
|
uptime: string;
|
|
@@ -64,9 +65,23 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
64
65
|
)}
|
|
65
66
|
</div>
|
|
66
67
|
))}
|
|
68
|
+
{/* MCP Server — separate row (no pid, uses port + sessions) */}
|
|
69
|
+
<div className="flex items-center gap-2 text-xs">
|
|
70
|
+
<span className={data.processes.mcp?.running ? 'text-green-400' : 'text-gray-500'}>●</span>
|
|
71
|
+
<span className="text-[var(--text-primary)] w-28">MCP Server</span>
|
|
72
|
+
{data.processes.mcp?.running ? (
|
|
73
|
+
<>
|
|
74
|
+
<span className="text-[var(--text-secondary)] font-mono text-[10px]">port: {data.processes.mcp.port}</span>
|
|
75
|
+
<span className="text-gray-500 font-mono text-[9px]">{data.processes.mcp.sessions} session(s)</span>
|
|
76
|
+
</>
|
|
77
|
+
) : (
|
|
78
|
+
<span className="text-gray-500 text-[10px]">stopped</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
67
81
|
{data.processes.tunnel.running && data.processes.tunnel.url && (
|
|
68
82
|
<div className="pl-6 text-[10px] text-[var(--accent)] truncate">{data.processes.tunnel.url}</div>
|
|
69
83
|
)}
|
|
84
|
+
<div className="border-t border-[var(--border)] my-1" />
|
|
70
85
|
</div>
|
|
71
86
|
</div>
|
|
72
87
|
|
|
@@ -82,6 +82,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
82
82
|
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
83
83
|
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
|
|
84
84
|
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean; type?: string }[]>([]);
|
|
85
|
+
const [boundSession, setBoundSession] = useState<{ sessionId: string } | null>(null);
|
|
86
|
+
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
87
|
+
const [availableSessions, setAvailableSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
|
|
85
88
|
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
|
86
89
|
const [expandedPipeline, setExpandedPipeline] = useState<any>(null);
|
|
87
90
|
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
@@ -401,7 +404,26 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
401
404
|
// Fetch git info and file tree in parallel
|
|
402
405
|
fetchGitInfo();
|
|
403
406
|
fetchTree();
|
|
404
|
-
|
|
407
|
+
// Fetch project-level fixed session
|
|
408
|
+
fetchBoundSession();
|
|
409
|
+
}, [projectPath, fetchGitInfo, fetchTree]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
410
|
+
|
|
411
|
+
const fetchBoundSession = useCallback(() => {
|
|
412
|
+
fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`)
|
|
413
|
+
.then(r => r.json())
|
|
414
|
+
.then(data => {
|
|
415
|
+
if (data?.fixedSessionId) setBoundSession({ sessionId: data.fixedSessionId });
|
|
416
|
+
else setBoundSession(null);
|
|
417
|
+
})
|
|
418
|
+
.catch(() => {});
|
|
419
|
+
}, [projectPath]);
|
|
420
|
+
|
|
421
|
+
// Listen for session binding changes (from SessionView or other components)
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
const handler = () => fetchBoundSession();
|
|
424
|
+
window.addEventListener('forge:session-bound', handler);
|
|
425
|
+
return () => window.removeEventListener('forge:session-bound', handler);
|
|
426
|
+
}, [fetchBoundSession]);
|
|
405
427
|
|
|
406
428
|
// Lazy load tab-specific data only when switching to that tab
|
|
407
429
|
useEffect(() => {
|
|
@@ -443,11 +465,78 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
443
465
|
</button>
|
|
444
466
|
</div>
|
|
445
467
|
</div>
|
|
446
|
-
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
|
|
447
|
-
{projectPath}
|
|
468
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-0.5 flex items-center gap-2 flex-wrap">
|
|
469
|
+
<span>{projectPath}</span>
|
|
448
470
|
{gitInfo?.remote && (
|
|
449
|
-
<span
|
|
471
|
+
<span>{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
450
472
|
)}
|
|
473
|
+
{/* Fixed session: show current or "set session" */}
|
|
474
|
+
<span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-[#f0883e]/10 text-[#f0883e] relative">
|
|
475
|
+
{boundSession?.sessionId ? (
|
|
476
|
+
<>
|
|
477
|
+
<span className="text-[8px]">session:</span>
|
|
478
|
+
<button onClick={() => {
|
|
479
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
|
|
480
|
+
.then(r => r.json())
|
|
481
|
+
.then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
|
|
482
|
+
.catch(() => {});
|
|
483
|
+
setShowSessionPicker(!showSessionPicker);
|
|
484
|
+
}}
|
|
485
|
+
className="font-mono text-[8px] hover:text-white underline decoration-dotted" title="Click to change">
|
|
486
|
+
{boundSession.sessionId.slice(0, 8)}
|
|
487
|
+
</button>
|
|
488
|
+
<button onClick={() => navigator.clipboard.writeText(boundSession.sessionId)}
|
|
489
|
+
className="text-[7px] hover:text-white" title={boundSession.sessionId}>copy</button>
|
|
490
|
+
</>
|
|
491
|
+
) : (
|
|
492
|
+
<button onClick={() => {
|
|
493
|
+
fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
|
|
494
|
+
.then(r => r.json())
|
|
495
|
+
.then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
|
|
496
|
+
.catch(() => {});
|
|
497
|
+
setShowSessionPicker(!showSessionPicker);
|
|
498
|
+
}}
|
|
499
|
+
className="text-[8px] hover:text-white underline decoration-dotted">
|
|
500
|
+
set session
|
|
501
|
+
</button>
|
|
502
|
+
)}
|
|
503
|
+
{/* Session picker dropdown */}
|
|
504
|
+
{showSessionPicker && (
|
|
505
|
+
<div className="absolute top-full left-0 mt-1 z-50 bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-xl p-2 min-w-[280px]"
|
|
506
|
+
onClick={e => e.stopPropagation()}>
|
|
507
|
+
<div className="text-[9px] text-[var(--text-secondary)] mb-1.5 font-medium">{boundSession?.sessionId ? 'Change session' : 'Select session'}</div>
|
|
508
|
+
<div className="max-h-48 overflow-y-auto space-y-1">
|
|
509
|
+
{availableSessions.map(s => (
|
|
510
|
+
<button key={s.id} onClick={async () => {
|
|
511
|
+
try {
|
|
512
|
+
await fetch('/api/project-sessions', {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
515
|
+
body: JSON.stringify({ projectPath, fixedSessionId: s.id }),
|
|
516
|
+
});
|
|
517
|
+
setBoundSession({ sessionId: s.id });
|
|
518
|
+
window.dispatchEvent(new Event('forge:session-bound'));
|
|
519
|
+
} catch {}
|
|
520
|
+
setShowSessionPicker(false);
|
|
521
|
+
}}
|
|
522
|
+
className={`w-full text-left px-2 py-1.5 rounded text-[9px] flex items-center gap-2 ${
|
|
523
|
+
s.id === boundSession?.sessionId
|
|
524
|
+
? 'bg-[#f0883e]/20 text-[#f0883e]'
|
|
525
|
+
: 'hover:bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
|
|
526
|
+
}`}>
|
|
527
|
+
<span className="font-mono">{s.id.slice(0, 8)}</span>
|
|
528
|
+
{s.id === boundSession?.sessionId && <span className="text-[7px]">current</span>}
|
|
529
|
+
{s.modified && <span className="text-[8px] opacity-60">{(() => { const d = Date.now() - new Date(s.modified).getTime(); return d < 3600000 ? `${Math.floor(d/60000)}m ago` : d < 86400000 ? `${Math.floor(d/3600000)}h ago` : new Date(s.modified).toLocaleDateString(); })()}</span>}
|
|
530
|
+
{s.size > 0 && <span className="text-[8px] opacity-60">{s.size < 1048576 ? `${(s.size/1024).toFixed(0)}KB` : `${(s.size/1048576).toFixed(1)}MB`}</span>}
|
|
531
|
+
</button>
|
|
532
|
+
))}
|
|
533
|
+
{availableSessions.length === 0 && <div className="text-[9px] text-[var(--text-secondary)] px-2 py-1">No sessions found</div>}
|
|
534
|
+
</div>
|
|
535
|
+
<button onClick={() => setShowSessionPicker(false)}
|
|
536
|
+
className="w-full mt-1.5 text-[8px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] pt-1 border-t border-[var(--border)]">Close</button>
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
</span>
|
|
451
540
|
</div>
|
|
452
541
|
{/* Tab switcher */}
|
|
453
542
|
<div className="flex items-center gap-2 mt-1.5">
|
|
@@ -1464,7 +1553,12 @@ function AgentTerminalButton({ projectPath, projectName }: { projectPath: string
|
|
|
1464
1553
|
</button>
|
|
1465
1554
|
|
|
1466
1555
|
{sessions.length > 0 && (
|
|
1467
|
-
<button onClick={() =>
|
|
1556
|
+
<button onClick={async () => {
|
|
1557
|
+
// Use fixedSession if available, otherwise latest session
|
|
1558
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1559
|
+
const fixedId = await resolveFixedSession(projectPath);
|
|
1560
|
+
openWithAgent(launchDialog.agentId, true, fixedId || sessions[0].id, launchDialog.env, launchDialog.model);
|
|
1561
|
+
}}
|
|
1468
1562
|
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
|
|
1469
1563
|
<div className="text-xs text-white font-semibold">Resume Latest</div>
|
|
1470
1564
|
<div className="text-[9px] text-gray-500">{sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}</div>
|
|
@@ -58,6 +58,8 @@ export default function SessionView({
|
|
|
58
58
|
const [batchMode, setBatchMode] = useState(false);
|
|
59
59
|
const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
|
|
60
60
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
61
|
+
// Project-level fixed session binding
|
|
62
|
+
const [boundSessions, setBoundSessions] = useState<Record<string, { sessionId: string }>>({});
|
|
61
63
|
|
|
62
64
|
// Load cached sessions tree
|
|
63
65
|
const loadTree = useCallback(async (force = false) => {
|
|
@@ -84,6 +86,21 @@ export default function SessionView({
|
|
|
84
86
|
} catch {}
|
|
85
87
|
}, []);
|
|
86
88
|
|
|
89
|
+
// Load project-level fixed session bindings
|
|
90
|
+
const loadBoundSessions = useCallback(async () => {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch('/api/project-sessions');
|
|
93
|
+
const all = await res.json(); // { "/path/to/project": "session-uuid", ... }
|
|
94
|
+
const bound: Record<string, { sessionId: string }> = {};
|
|
95
|
+
for (const p of projects) {
|
|
96
|
+
if (all[p.path]) {
|
|
97
|
+
bound[p.name] = { sessionId: all[p.path] };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
setBoundSessions(bound);
|
|
101
|
+
} catch {}
|
|
102
|
+
}, [projects]);
|
|
103
|
+
|
|
87
104
|
useEffect(() => {
|
|
88
105
|
// In single-project mode: load cached first (fast), then sync in background
|
|
89
106
|
if (singleProject) {
|
|
@@ -92,7 +109,10 @@ export default function SessionView({
|
|
|
92
109
|
loadTree(true);
|
|
93
110
|
}
|
|
94
111
|
loadWatchers();
|
|
95
|
-
|
|
112
|
+
// Defer bound session loading to avoid blocking initial render
|
|
113
|
+
const t = setTimeout(loadBoundSessions, 500);
|
|
114
|
+
return () => clearTimeout(t);
|
|
115
|
+
}, [loadTree, loadWatchers, loadBoundSessions, singleProject]);
|
|
96
116
|
|
|
97
117
|
// Auto-expand project if only one or if pre-selected
|
|
98
118
|
useEffect(() => {
|
|
@@ -403,32 +423,60 @@ export default function SessionView({
|
|
|
403
423
|
{s.summary || s.firstPrompt?.slice(0, 40) || s.sessionId.slice(0, 8)}
|
|
404
424
|
</span>
|
|
405
425
|
{isWatched && <span className="text-[8px] text-[var(--accent)]">👁</span>}
|
|
406
|
-
|
|
426
|
+
</div>
|
|
427
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
428
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">{s.sessionId.slice(0, 8)}</span>
|
|
429
|
+
{boundSessions[project]?.sessionId === s.sessionId && (
|
|
430
|
+
<span className="text-[7px] px-1 py-0 rounded bg-[#f0883e]/20 text-[#f0883e] font-medium">bound</span>
|
|
431
|
+
)}
|
|
432
|
+
{s.gitBranch && <span className="text-[8px] text-[var(--accent)]">{s.gitBranch}</span>}
|
|
433
|
+
{s.modified && (
|
|
434
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
435
|
+
{timeAgo(s.modified)}
|
|
436
|
+
</span>
|
|
437
|
+
)}
|
|
438
|
+
{/* Actions — right side */}
|
|
407
439
|
{!batchMode && (
|
|
408
|
-
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0">
|
|
440
|
+
<span className="hidden group-hover:flex items-center gap-0.5 ml-auto shrink-0">
|
|
441
|
+
<button
|
|
442
|
+
onClick={(e) => {
|
|
443
|
+
e.stopPropagation();
|
|
444
|
+
const pp = projects.find(p => p.name === project)?.path || '';
|
|
445
|
+
if (pp && onOpenInTerminal) onOpenInTerminal(s.sessionId, pp);
|
|
446
|
+
else if (pp) window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail: { projectPath: pp, projectName: project, agentId: 'claude', resumeMode: true, sessionId: s.sessionId } }));
|
|
447
|
+
}}
|
|
448
|
+
className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400 hover:bg-green-500/20"
|
|
449
|
+
title="Open this session in terminal"
|
|
450
|
+
>open</button>
|
|
451
|
+
{boundSessions[project]?.sessionId !== s.sessionId && (
|
|
452
|
+
<button
|
|
453
|
+
onClick={async (e) => {
|
|
454
|
+
e.stopPropagation();
|
|
455
|
+
const pp = projects.find(p => p.name === project)?.path || '';
|
|
456
|
+
if (!pp) return;
|
|
457
|
+
const res = await fetch('/api/project-sessions', {
|
|
458
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({ projectPath: pp, fixedSessionId: s.sessionId }),
|
|
460
|
+
});
|
|
461
|
+
if (res.ok) {
|
|
462
|
+
setBoundSessions(prev => ({ ...prev, [project]: { sessionId: s.sessionId } }));
|
|
463
|
+
window.dispatchEvent(new Event('forge:session-bound'));
|
|
464
|
+
}
|
|
465
|
+
}}
|
|
466
|
+
className="text-[8px] px-1 py-0.5 rounded bg-[#f0883e]/10 text-[#f0883e] hover:bg-[#f0883e]/20"
|
|
467
|
+
title="Set as fixed session"
|
|
468
|
+
>bind</button>
|
|
469
|
+
)}
|
|
409
470
|
<button
|
|
410
471
|
onClick={(e) => { e.stopPropagation(); createMonitorTask(project, s.sessionId); }}
|
|
411
472
|
className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
|
|
412
|
-
title="
|
|
413
|
-
>
|
|
414
|
-
monitor
|
|
415
|
-
</button>
|
|
473
|
+
title="Monitor via Telegram"
|
|
474
|
+
>monitor</button>
|
|
416
475
|
<button
|
|
417
476
|
onClick={(e) => { e.stopPropagation(); deleteSessionById(project, s.sessionId); }}
|
|
418
477
|
className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20"
|
|
419
478
|
title="Delete session"
|
|
420
|
-
>
|
|
421
|
-
del
|
|
422
|
-
</button>
|
|
423
|
-
</span>
|
|
424
|
-
)}
|
|
425
|
-
</div>
|
|
426
|
-
<div className="flex items-center gap-2 mt-0.5">
|
|
427
|
-
<span className="text-[8px] text-[var(--text-secondary)] font-mono">{s.sessionId.slice(0, 8)}</span>
|
|
428
|
-
{s.gitBranch && <span className="text-[8px] text-[var(--accent)]">{s.gitBranch}</span>}
|
|
429
|
-
{s.modified && (
|
|
430
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
431
|
-
{timeAgo(s.modified)}
|
|
479
|
+
>del</button>
|
|
432
480
|
</span>
|
|
433
481
|
)}
|
|
434
482
|
</div>
|