Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
886 changes: 796 additions & 90 deletions apps/staged/src-tauri/src/pikchr_mcp.rs

Large diffs are not rendered by default.

1,035 changes: 765 additions & 270 deletions apps/staged/src-tauri/src/pikchr_subsession.rs

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions apps/staged/src-tauri/src/session_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,42 @@ impl SessionRegistry {
pub fn is_running(&self, session_id: &str) -> bool {
self.inner.lock().unwrap().running.contains_key(session_id)
}

/// Register a session whose work is driven outside `start_session` (e.g. a
/// pikchr diagram child session run by a `generate_pikchr` worker thread),
/// so a user cancel reaches the actual work instead of taking
/// `cancel_session`'s store-write fallback, which the worker never
/// observes. Returns a guard exposing the session's cancellation token;
/// dropping the guard deregisters the session.
pub fn register_external(self: &Arc<Self>, session_id: &str) -> ExternalSessionRegistration {
ExternalSessionRegistration {
token: self.register(session_id),
registry: Arc::clone(self),
session_id: session_id.to_string(),
}
}
}

/// Registry entry for an externally driven session, from
/// [`SessionRegistry::register_external`]. Holds the session in the registry —
/// where [`SessionRegistry::cancel`] can reach its token — until dropped.
pub struct ExternalSessionRegistration {
registry: Arc<SessionRegistry>,
session_id: String,
token: CancellationToken,
}

impl ExternalSessionRegistration {
/// The token [`SessionRegistry::cancel`] fires for this session.
pub fn token(&self) -> &CancellationToken {
&self.token
}
}

impl Drop for ExternalSessionRegistration {
fn drop(&mut self) {
self.registry.deregister(&self.session_id);
}
}

// =============================================================================
Expand Down Expand Up @@ -582,7 +618,10 @@ pub fn start_session(
let pikchr_provider = resolved_provider_id.clone().unwrap_or_default();
match crate::pikchr_mcp::start_pikchr_mcp_server(
pikchr_provider,
config.session_id.clone(),
app_handle.clone(),
Arc::clone(&store),
Arc::clone(&registry),
)
.await
{
Expand Down Expand Up @@ -3450,6 +3489,26 @@ mod tests {
);
}

#[test]
fn register_external_exposes_token_to_registry_cancel_until_dropped() {
let registry = Arc::new(SessionRegistry::new());

let registration = registry.register_external("diagram-child");
let token = registration.token().clone();
assert!(registry.is_running("diagram-child"));
assert!(!token.is_cancelled());

assert!(registry.cancel("diagram-child"));
assert!(token.is_cancelled());

drop(registration);
assert!(!registry.is_running("diagram-child"));
assert!(
!registry.cancel("diagram-child"),
"a dropped registration must leave nothing behind for cancel to find"
);
}

#[test]
fn cancel_or_defer_cancels_already_registered_session() {
let registry = SessionRegistry::new();
Expand Down
7 changes: 6 additions & 1 deletion apps/staged/src-tauri/src/store/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ impl Store {
/// exist. This is the safe path for background threads — it prevents
/// a late-arriving "completed" write from overwriting a "cancelled"
/// status that was set by a concurrent cancel request.
///
/// Unlike the other status writers, `error_message` is persisted for
/// `Cancelled` as well as `Error`: a run cancelled from outside (e.g. a
/// Pikchr sub-session hitting its tool-call timeout) can carry an
/// explanation of why it ended. Other statuses clear the message.
pub fn transition_from_running(
&self,
id: &str,
Expand All @@ -89,7 +94,7 @@ impl Store {
completion_reason: Option<&CompletionReason>,
) -> Result<bool, StoreError> {
let conn = self.conn.lock().unwrap();
let error_msg = if new_status == SessionStatus::Error {
let error_msg = if matches!(new_status, SessionStatus::Error | SessionStatus::Cancelled) {
error_message
} else {
None
Expand Down
40 changes: 40 additions & 0 deletions apps/staged/src-tauri/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,46 @@ fn test_transition_from_running_succeeds_when_running() {
assert_eq!(final_state.status, SessionStatus::Completed);
}

#[test]
fn test_transition_from_running_keeps_message_for_cancelled() {
let store = Store::in_memory().unwrap();

let session = Session::new_running("timed out", Path::new("/tmp"));
store.create_session(&session).unwrap();

// A cancelled run may carry an explanation (e.g. a Pikchr sub-session
// killed by its tool-call timeout); completed runs still clear it.
let transitioned = store
.transition_from_running(
&session.id,
SessionStatus::Cancelled,
Some("the call timed out"),
Some(&CompletionReason::Interrupted),
)
.unwrap();
assert!(transitioned);

let final_state = store.get_session(&session.id).unwrap().unwrap();
assert_eq!(final_state.status, SessionStatus::Cancelled);
assert_eq!(
final_state.error_message.as_deref(),
Some("the call timed out")
);

let completed = Session::new_running("clean finish", Path::new("/tmp"));
store.create_session(&completed).unwrap();
store
.transition_from_running(
&completed.id,
SessionStatus::Completed,
Some("should be dropped"),
None,
)
.unwrap();
let completed_state = store.get_session(&completed.id).unwrap().unwrap();
assert!(completed_state.error_message.is_none());
}

#[test]
fn test_transition_from_active_succeeds_when_queued() {
let store = Store::in_memory().unwrap();
Expand Down
19 changes: 19 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,23 @@
}
}

function handleOpenInnerSession(sessionId: string) {
const current = currentDialogReferenceEntry();
if (current) pushReferenceEntry(current);
pushReferenceEntry({
kind: 'chat',
ref: `#chat:${sessionId}`,
sessionId,
branchId: branch.id,
projectId: branch.projectId,
repoDir: branch.worktreePath,
repoLabel,
hashtagItems,
diffContext: referenceDiffContext,
});
closeReferenceDialogs();
}

function handleDeleteImage(imageId: string, opts?: { altKey: boolean }) {
const doDelete = async () => {
confirmDelete = null;
Expand Down Expand Up @@ -1899,6 +1916,7 @@
nextSteps={openNote.nextSteps}
{hashtagItems}
referenceNav={disabledReferenceNav}
onOpenSession={handleOpenInnerSession}
onClose={() => (openNote = null)}
onHashtagClick={handleHashtagClick}
onStartSession={(mode, prefill) => {
Expand Down Expand Up @@ -1985,6 +2003,7 @@
{repoLabel}
referenceNav={disabledReferenceNav}
onClose={handleSessionModalClose}
onOpenSession={handleOpenInnerSession}
onHashtagClick={handleHashtagClick}
/>
{/if}
Expand Down
19 changes: 19 additions & 0 deletions apps/staged/src/lib/features/diff/DiffModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,23 @@
}
}

function handleOpenInnerSession(sessionId: string) {
const current = currentDialogReferenceEntry();
if (current) pushReferenceEntry(current);
pushReferenceEntry({
kind: 'chat',
ref: `#chat:${sessionId}`,
sessionId,
branchId,
projectId,
repoDir: branch?.worktreePath,
repoLabel: githubRepo ? { githubRepo, subpath: subpath ?? null, headRepo: null } : null,
hashtagItems,
diffContext: referenceDiffContext,
});
closeReferenceDialogs();
}

// Create tracker for search initialization
const checkSearchInitialization = createSearchInitializationTracker({
searchState,
Expand Down Expand Up @@ -1787,6 +1804,7 @@
onClose={() => {
openSessionId = null;
}}
onOpenSession={handleOpenInnerSession}
onHashtagClick={handleHashtagClick}
/>

Expand All @@ -1808,6 +1826,7 @@
}}
{hashtagItems}
referenceNav={disabledReferenceNav}
onOpenSession={handleOpenInnerSession}
onClose={() => {
openNote = null;
}}
Expand Down
2 changes: 2 additions & 0 deletions apps/staged/src/lib/features/notes/NoteModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
onClose,
sessionId,
noteUpdatedAt,
onOpenSession,
noteId,
noteKind = 'branch',
branchId,
Expand Down Expand Up @@ -640,6 +641,7 @@
{repoLabel}
{hashtagItems}
{noteInfo}
{onOpenSession}
onSessionChange={handlePaneSessionChange}
{onHashtagClick}
/>
Expand Down
16 changes: 16 additions & 0 deletions apps/staged/src/lib/features/projects/ProjectSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,21 @@
}
}

function handleOpenInnerSession(sessionId: string) {
const current = currentDialogReferenceEntry();
if (current) pushReferenceEntry(current);
pushReferenceEntry({
kind: 'chat',
ref: `#chat:${sessionId}`,
sessionId,
projectId: project.id,
repoDir: projectDisplayRootCandidates,
hashtagItems,
diffContext: referenceDiffContext,
});
closeReferenceDialogs();
}

// ── Lifecycle ──────────────────────────────────────────────────────────

onMount(() => {
Expand Down Expand Up @@ -567,6 +582,7 @@
openNote = null;
void loadProjectNotes();
}}
onOpenSession={handleOpenInnerSession}
onHashtagClick={handleHashtagClick}
/>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@
activateEntry(target);
}

function handleOpenInnerSession(sessionId: string) {
const current = currentReferenceEntry();
if (!current || current.kind === 'diff' || current.kind === 'image') return;

pushReferenceEntry({
kind: 'chat',
ref: `#chat:${sessionId}`,
sessionId,
branchId: current.branchId,
projectId: current.projectId,
repoDir: current.repoDir,
repoLabel: current.repoLabel,
hashtagItems: current.hashtagItems,
diffContext: current.diffContext,
});
}

function noteEntryForChat(entry: ReferenceChatEntry): ReferenceNoteEntry | null {
const note = noteItemForSession(entry.sessionId, entry.hashtagItems);
if (!note?.noteContent && note?.noteContent !== '') return null;
Expand Down Expand Up @@ -129,6 +146,7 @@
onChatOpenChange={(open) => setCurrentNoteView(open ? 'chat' : 'note')}
hashtagItems={entry.hashtagItems}
{referenceNav}
onOpenSession={handleOpenInnerSession}
onClose={handleClose}
onHashtagClick={handleHashtagClick}
/>
Expand All @@ -152,6 +170,7 @@
replaceCurrentReferenceEntry({ ...noteEntry, view: open ? 'chat' : 'note' })}
hashtagItems={noteEntry.hashtagItems}
{referenceNav}
onOpenSession={handleOpenInnerSession}
onClose={handleClose}
onHashtagClick={handleHashtagClick}
/>
Expand All @@ -165,6 +184,7 @@
repoLabel={entry.repoLabel}
hashtagItems={entry.hashtagItems}
{referenceNav}
onOpenSession={handleOpenInnerSession}
onClose={handleClose}
onHashtagClick={handleHashtagClick}
/>
Expand Down
Loading