Skip to content

Commit 5b3a9e6

Browse files
authored
fix: Link opencode session and opencode tui extension
* fix: properly isolate workflow state by OpenCode session ID The TUI plugin was incorrectly falling back to the most recently modified workflow state when no state was found for the current session. This caused new OpenCode sessions to display workflow state from previous sessions. Changes: 1. Remove fallback to readLatestState() in TUI plugin initialization 2. Remove fallback to readLatestState() in event handler 3. Remove unused readLatestState() function 4. Only use session ID-based state lookup to ensure proper isolation This ensures that when a new OpenCode session starts with no active workflow, the TUI correctly shows no workflow state instead of showing the previous session's workflow. * ui: show 'No Active Workflow' in TUI when no workflow is active for the session When the TUI plugin detects that no workflow state exists for the current OpenCode session, it now explicitly displays 'No Active Workflow' instead of hiding the component. This makes it clear that no workflow is currently active, rather than being ambiguous about the state. This improves UX by: - Making the workflow state always visible - Being explicit about the absence of an active workflow - Guiding users to start a workflow when needed
1 parent 560ecee commit 5b3a9e6

2 files changed

Lines changed: 51 additions & 59 deletions

File tree

packages/opencode-plugin/src/plugin.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,16 @@ export const WorkflowsPlugin: Plugin = async (
123123
const planManager = new PlanManager();
124124
const instructionGenerator = new InstructionGenerator();
125125

126-
// Cached ServerContext - created once, reused for all requests
126+
// Cached ServerContext - created once, reused for all requests within a session
127127
// This avoids creating new WorkflowManager/PluginRegistry instances per request
128+
// When the OpenCode session changes, the context is invalidated to prevent
129+
// showing workflow state from a previous session
128130
let cachedServerContext: Awaited<
129131
ReturnType<typeof createServerContext>
130132
> | null = null;
131133
let serverContextInitialized = false;
132134
let currentSessionId: string | null = null;
135+
let lastKnownSessionId: string | null = null;
133136

134137
// Buffered instructions from tools (proceed_to_phase, start_development).
135138
// Consumed and cleared by the next chat.message hook call.
@@ -149,8 +152,21 @@ export const WorkflowsPlugin: Plugin = async (
149152
}
150153

151154
// Helper to get an initialized ServerContext for handler delegation
152-
// Creates once, reuses for all subsequent calls
155+
// Creates once per session, reuses for all subsequent calls within the same session.
156+
// If the session ID changes, the cached context is invalidated to prevent
157+
// showing workflow state from a previous OpenCode session.
153158
async function getServerContext() {
159+
// Invalidate cache if session ID has changed
160+
if (currentSessionId && currentSessionId !== lastKnownSessionId) {
161+
logger.debug('Session ID changed, invalidating cached ServerContext', {
162+
oldSessionId: lastKnownSessionId,
163+
newSessionId: currentSessionId,
164+
});
165+
cachedServerContext = null;
166+
serverContextInitialized = false;
167+
lastKnownSessionId = currentSessionId;
168+
}
169+
154170
if (!cachedServerContext) {
155171
const sessionMetadata = currentSessionId
156172
? {
@@ -234,10 +250,22 @@ export const WorkflowsPlugin: Plugin = async (
234250
* We add a synthetic part with phase instructions.
235251
*/
236252
'chat.message': async (hookInput, output) => {
237-
// Capture session ID from the first hook that has it
238-
if (hookInput.sessionID && !currentSessionId) {
239-
currentSessionId = hookInput.sessionID;
240-
logger.debug('Captured session ID', { sessionId: currentSessionId });
253+
// Capture session ID and detect session changes
254+
if (hookInput.sessionID) {
255+
if (!currentSessionId) {
256+
currentSessionId = hookInput.sessionID;
257+
lastKnownSessionId = hookInput.sessionID;
258+
logger.debug('Captured initial session ID', {
259+
sessionId: currentSessionId,
260+
});
261+
} else if (currentSessionId !== hookInput.sessionID) {
262+
// Session has changed - the getServerContext() function will handle invalidation
263+
currentSessionId = hookInput.sessionID;
264+
logger.info('Session ID changed', {
265+
oldSessionId: lastKnownSessionId,
266+
newSessionId: currentSessionId,
267+
});
268+
}
241269
}
242270

243271
// Skip if workflows are disabled

packages/opencode-tui-plugin/workflows-phase.tsx

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -86,49 +86,6 @@ function readStateBySessionId(
8686
}
8787
}
8888

89-
/**
90-
* Fallback: Read the most recently modified state.
91-
* Used when no matching session ID is found or for backward compatibility.
92-
* This is deprecated in favor of sessionId-based lookup.
93-
*/
94-
function readLatestState(
95-
sessionDir: string
96-
): { phase: string; workflow: string } | null {
97-
try {
98-
// require() is intentional: top-level ESM imports of Node built-ins are not
99-
// supported in the Bun plugin runtime.
100-
// eslint-disable-next-line @typescript-eslint/no-require-imports
101-
const fsSync = require('node:fs') as typeof fs;
102-
// eslint-disable-next-line @typescript-eslint/no-require-imports
103-
const pathSync = require('node:path') as typeof path;
104-
const vibeDir = pathSync.join(sessionDir, '.vibe', 'conversations');
105-
const dirs = fsSync.readdirSync(vibeDir);
106-
let latest: { mtime: number; file: string } | null = null;
107-
for (const dir of dirs) {
108-
const file = pathSync.join(vibeDir, dir, 'state.json');
109-
try {
110-
const stat = fsSync.statSync(file);
111-
if (!latest || stat.mtimeMs > latest.mtime) {
112-
latest = { mtime: stat.mtimeMs, file };
113-
}
114-
} catch {
115-
// unreadable entry — skip silently
116-
}
117-
}
118-
if (!latest) return null;
119-
const state = JSON.parse(
120-
fsSync.readFileSync(latest.file, 'utf8')
121-
) as StateJson;
122-
if (!state.currentPhase && !state.workflowName) return null;
123-
return {
124-
phase: state.currentPhase ?? '—',
125-
workflow: state.workflowName ?? '—',
126-
};
127-
} catch {
128-
return null;
129-
}
130-
}
131-
13289
// eslint-disable-next-line @typescript-eslint/require-await -- TuiPlugin signature requires Promise<void>; plugin body is synchronous
13390
const tui: TuiPlugin = async api => {
13491
api.slots.register({
@@ -145,9 +102,11 @@ const tui: TuiPlugin = async api => {
145102
// not only after the first tool call.
146103
const dir = api.state.path.directory;
147104
if (dir) {
148-
// Try session ID-based lookup first, fall back to most recent state
105+
// ONLY use session ID-based lookup. Do NOT fall back to readLatestState,
106+
// as that would show workflow state from a different session.
107+
// If no matching session state exists, that means no workflow is active in this session.
149108
const stateBySession = readStateBySessionId(dir, props.session_id);
150-
setState(stateBySession || readLatestState(dir));
109+
setState(stateBySession);
151110
}
152111

153112
const offPart = api.event.on('message.part.updated', e => {
@@ -158,23 +117,28 @@ const tui: TuiPlugin = async api => {
158117
if (part.type !== 'tool') return;
159118
if (!part.tool || !WORKFLOW_TOOLS.has(part.tool)) return;
160119
if (!dir) return;
161-
// Use session ID-based lookup to get the correct state for this session
120+
// ONLY use session ID-based lookup. Do NOT fall back to readLatestState,
121+
// as that would show workflow state from a different session.
162122
const stateBySession = readStateBySessionId(dir, props.session_id);
163-
setState(stateBySession || readLatestState(dir));
123+
setState(stateBySession);
164124
});
165125
onCleanup(offPart);
166126

167127
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSX element typed as `error` by @opentui/solid's JSX types; safe at runtime
168128
return (
169-
<box flexDirection="column" visible={!!state()}>
129+
<box flexDirection="column">
170130
<text fg={theme().text}>
171131
<b>Workflow</b>
172132
</text>
173-
<text fg={theme().textMuted}>
174-
{state()?.workflow}:{' '}
175-
{/* eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property */}
176-
<span style={{ fg: theme().text }}>{state()?.phase}</span>
177-
</text>
133+
{state() ? (
134+
<text fg={theme().textMuted}>
135+
{state()?.workflow}:{' '}
136+
{/* eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property */}
137+
<span style={{ fg: theme().text }}>{state()?.phase}</span>
138+
</text>
139+
) : (
140+
<text fg={theme().textMuted}>No Active Workflow</text>
141+
)}
178142
</box>
179143
);
180144
},

0 commit comments

Comments
 (0)