Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 57 additions & 17 deletions gui/views/chat/ChatWorkflow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
Top-level chat-style page for a single MCP workflow. Owns the message
transcript and a single in-flight run; each user prompt triggers one
/plugin/mcp/execute call and appends the resulting assistant message to
the transcript. The composer is disabled while a run is in progress.
the transcript.

A run belongs to the server, so leaving the page only stops the poller. The
run_id rides on its assistant message, and a remount re-attaches. Only the
composer is gated during a run, because this view polls one at a time.

This component intentionally does not maintain a server-side session: the
backend is still single-shot. The chat UI gives users a familiar Claude.ai
Expand All @@ -25,15 +29,16 @@
class="back-button"
@click="$emit('back')"
type="button"
:disabled="run.isRunning.value"
:title="run.isRunning.value
? 'Cannot leave while a run is in progress'
: 'Back to workflow list'"
title="Back to workflow list"
>
<font-awesome-icon :icon="backIcon" />
<span>Back</span>
</button>
<span v-if="run.isRunning.value" class="header-status running">
<span
v-if="run.isRunning.value"
class="header-status running"
title="This run continues on the server if you leave the page."
>
<span class="status-dot"></span> Working
</span>
<span v-else-if="messages.length" class="header-status idle">
Expand All @@ -48,7 +53,6 @@
class="header-action history-toggle"
:class="{ 'is-on': historyEnabled }"
@click="historyEnabled = !historyEnabled"
:disabled="run.isRunning.value"
type="button"
:title="historyEnabled
? 'Chat history is being threaded into each prompt. Click to disable for the rest of this session.'
Expand All @@ -61,9 +65,10 @@
v-if="messages.length"
class="header-action"
@click="clearTranscript"
:disabled="run.isRunning.value"
type="button"
title="Start a new chat"
:title="run.isRunning.value
? 'Start a new chat. The run in progress keeps going on the server and stays in the History tab.'
: 'Start a new chat'"
>
<font-awesome-icon :icon="plusIcon" />
<span>New chat</span>
Expand Down Expand Up @@ -183,6 +188,8 @@ onMounted(() => {
// before the first render pass. Has to happen before the height sync so
// the transcript scroll position settles correctly on a hydrated view.
session.hydrate()
// hydrate() leaves a still-running bubble RUNNING; give it a fresh poller.
_resumeRunInFlight()

prevBodyOverflow = document.body.style.overflow
prevHtmlOverflow = document.documentElement.style.overflow
Expand All @@ -207,10 +214,12 @@ const { thoughts, adversary, abilityNames, splitSentences, isInjectedSentence }

// Sync the in-flight run into its assistant message as state changes. The
// assistant message is created at submit time with a known id; we update
// that message in place rather than re-pushing.
// that message in place rather than re-pushing. runId is watched too so the
// handle reaches the transcript without waiting for the first poll.
let pendingAssistantId = null
watch(
() => ({
runId: run.runId.value,
status: run.status.value,
stage: run.stage.value,
finalResult: run.finalResult.value,
Expand All @@ -222,6 +231,7 @@ watch(
if (!pendingAssistantId) return
const msg = messages.value.find(m => m.id === pendingAssistantId)
if (!msg) return
msg.runId = run.runId.value
msg.status = run.status.value
msg.stage = run.stage.value
msg.finalResult = run.finalResult.value
Expand All @@ -237,10 +247,26 @@ watch(
{ deep: true }
)

// Only the newest bubble can still own a run: hydrate() has already failed
// every older RUNNING message.
function _resumeRunInFlight() {
const pending = [...messages.value]
.reverse()
.find(m => m.role === 'assistant' && m.status === 'RUNNING' && m.runId)
if (!pending) return
pendingAssistantId = pending.id
run.attach(pending.runId)
}

// Without this the 1s interval outlives the view. The run itself keeps going.
onBeforeUnmount(run.stop)

// --- Workflow-derived UI bits ----------------------------------------------
const examplePrompts = computed(() => props.workflow?.example_prompts || [])
const composerPlaceholder = computed(() => {
if (run.isRunning.value) return 'Working on the previous request…'
// The box is closed, but the page is not: say so.
if (run.isRunning.value)
return 'Working on the previous request. You can leave this page, it keeps running…'
if (messages.value.length) {
if (historyActive.value) return 'Send a follow-up prompt in this session…'
if (supportsChatHistory.value)
Expand Down Expand Up @@ -269,6 +295,8 @@ function handleSubmit() {
messages.value.push({
id: assistantId,
role: 'assistant',
// Set by _recordRunHandle; the handle a later mount re-attaches with.
runId: null,
status: 'RUNNING',
stage: '',
finalResult: '',
Expand All @@ -280,10 +308,20 @@ function handleSubmit() {
})

composerText.value = ''
_startRun(text)
_startRun(text, assistantId)
}

async function _startRun(text) {
// Written imperatively because Vue stops the watchers on unmount: clicking
// Back mid-POST would otherwise store a live run with runId null, and the
// next mount would call it failed.
function _recordRunHandle(assistantId, resp) {
if (resp.session_id && !sessionId.value) sessionId.value = resp.session_id
const msg = messages.value.find(m => m.id === assistantId)
if (msg) msg.runId = resp.run_id || null
session.persist()
}

async function _startRun(text, assistantId) {
const context = workflowContext.value || {}
const selectedCtiFiles = Array.isArray(context.selected_stix_files)
? context.selected_stix_files
Expand Down Expand Up @@ -367,16 +405,18 @@ async function _startRun(text) {

try {
const resp = await run.start(payload)
if (resp?.session_id && !sessionId.value) {
sessionId.value = resp.session_id
}
// null means "New chat" wiped this transcript mid-POST; adopting the ids
// would thread the fresh chat onto the run the user walked away from.
if (resp) _recordRunHandle(assistantId, resp)
} catch {
// run.errorMessage is already populated by useMcpRun.
}
}

function clearTranscript() {
if (run.isRunning.value) return
// Allowed mid-run: only this view's link to the run is lost, which is the
// point. The run carries on and stays reachable in the History tab.
//
// "New chat" is the only path that wipes durable session state. The
// composable resets messages, sessionId, historyEnabled, and
// selectedRag in memory AND removes this workflow's slice from
Expand Down
55 changes: 29 additions & 26 deletions gui/views/chat/composables/useChatSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@
// What it does not own:
// composerText, transient run-state (status/stage/finalResult on the
// in-flight assistant message). Composer text is intentionally not
// persisted — half-typed prompts surviving a remount is a worse UX
// than a clean text box. In-flight run state belongs to useMcpRun
// and is reconstructed from the polling endpoint, not localStorage.
// persisted: half-typed prompts surviving a remount is a worse UX
// than a clean text box. In-flight run state belongs to useMcpRun and
// is reconstructed from the polling endpoint; localStorage only keeps
// the run_id needed to ask for it again.
//
// Storage shape:
// localStorage key 'mcp_chat_sessions' holds:
Expand All @@ -36,11 +37,10 @@
// The schema version lets future changes drop incompatible blobs
// without confusing rehydration.
//
// On hydrate the composable also marks any message whose status is
// still 'RUNNING' as FAILED with a "polling stopped" note. Polling
// timers do not survive a component unmount; leaving the bubble
// frozen on "Thinking" forever is misleading. The MLflow run is still
// findable via the History tab if the user needs the result.
// Assistant messages carry their run_id, so a bubble left mid-run survives
// a remount and ChatWorkflow re-attaches a poller to it. Older RUNNING
// messages, and any with no run_id, are failed on hydrate: nothing is going
// to move them again.
//
// Limits:
// Per-workflow caps at MAX_MESSAGES messages and MAX_BYTES total
Expand Down Expand Up @@ -101,24 +101,24 @@ function _trimMessages(messages) {
return trimmed
}

function _markStaleRunningAsFailed(messages) {
// A polling timer cannot survive a component unmount. Any message
// that was RUNNING when we last saved is no longer being updated;
// surfacing the stale state would lie to the user. Mark it FAILED
// with a hint so the user knows where to look (History tab).
return messages.map(m => {
if (m.status === 'RUNNING') {
return {
...m,
status: 'FAILED',
stage: '',
errorMessage:
m.errorMessage
|| 'Polling stopped when you navigated away. '
+ 'Check the History tab for the final result.',
}
function _markUnresumableRunning(messages) {
// Only the newest RUNNING message can be re-attached; the view polls one
// run at a time. A bubble left frozen on "Thinking" would lie to the user.
const resumableIndex = messages.reduce(
(last, m, i) => (m.status === 'RUNNING' && m.runId ? i : last),
-1
)
return messages.map((m, i) => {
if (m.status !== 'RUNNING' || i === resumableIndex) return m
return {
...m,
status: 'FAILED',
stage: '',
errorMessage:
m.errorMessage
|| 'The page stopped tracking this run. '
+ 'Check the History tab for the final result.',
}
return m
})
}

Expand All @@ -131,7 +131,7 @@ export function useChatSession(workflowId) {
function hydrate() {
const slice = _readWorkflow(workflowId)
if (!slice) return
messages.value = _markStaleRunningAsFailed(slice.messages || [])
messages.value = _markUnresumableRunning(slice.messages || [])
sessionId.value = slice.sessionId ?? null
historyEnabled.value =
typeof slice.historyEnabled === 'boolean' ? slice.historyEnabled : true
Expand Down Expand Up @@ -172,6 +172,9 @@ export function useChatSession(workflowId) {
historyEnabled,
selectedRag,
hydrate,
// For the one write that cannot wait for the watcher: a run_id arriving
// after unmount, once the scope holding that watcher is stopped.
persist,
reset,
}
}
Loading
Loading