diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 20f4937cb..ee2a3995d 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -1527,6 +1527,7 @@ pub(crate) async fn resume_or_queue_session_for_store( branch_id: Option, acp_config_selection: Option, ) -> Result { + ensure_session_chat_writable(&store, &session_id)?; let session = store .get_session(&session_id) .map_err(|e| e.to_string())? @@ -2411,6 +2412,7 @@ pub(crate) async fn send_session_message_now_for_store( message: &str, image_ids: &[String], ) -> Result { + ensure_session_chat_writable(store, session_id)?; match route_continue_session( store, registry, @@ -2808,6 +2810,32 @@ fn continue_branch_and_type( Ok((lookup(branch_id)?, None)) } +/// The error a user's chat message gets when its session's note is a child +/// aggregated under a project note; see [`ensure_session_chat_writable`]. +pub(crate) const HIDDEN_CHILD_NOTE_CHAT_ERROR: &str = + "This chat is read-only: its note is part of \ + a project note and is not shown in the branch's notes, so it can't take new messages."; + +/// Refuse a user's chat message for a session whose note is a child +/// aggregated under a project note (`Note::parent_project_note_id`). Such +/// notes are hidden from the branch timeline and the project notes list +/// (see `Store::list_notes_for_branch`), so their chats are read-only: the +/// project session that spawned them owns any follow-up. Gates the +/// user-facing entry points only (resume, queue, send now, desktop and web); +/// the project MCP server's `continue_repo_session` reaches +/// `route_continue_session` directly and is unaffected. +pub(crate) fn ensure_session_chat_writable(store: &Store, session_id: &str) -> Result<(), String> { + let note = store + .get_note_by_session(session_id) + .map_err(|e| e.to_string())?; + match note { + Some(note) if note.parent_project_note_id.is_some() => { + Err(HIDDEN_CHILD_NOTE_CHAT_ERROR.to_string()) + } + _ => Ok(()), + } +} + #[tauri::command] pub fn queue_session_message( store: tauri::State<'_, Mutex>>>, @@ -2817,6 +2845,7 @@ pub fn queue_session_message( branch_id: Option, ) -> Result { let store = get_store(&store)?; + ensure_session_chat_writable(&store, &session_id)?; store .add_queued_session_message( &session_id, @@ -14967,6 +14996,83 @@ mod tests { assert_eq!(std::fs::read(&media[0].target).unwrap(), PNG); } + /// A child note's session (`parent_project_note_id` set) and a + /// standalone note's session, both finished, for the chat gate tests. + fn child_and_standalone_note_sessions( + store: &Store, + branch: &store::Branch, + ) -> (String, String) { + let parent = store::ProjectNote::new(&branch.project_id, "Parent", "aggregated"); + store.create_project_note(&parent).unwrap(); + + let child_session = store::Session::new_queued("child").with_branch(&branch.id); + store.create_session(&child_session).unwrap(); + let child = store::Note::new(&branch.id, "Child", "child body") + .with_session(&child_session.id) + .with_parent_project_note(&parent.id); + store.create_note(&child).unwrap(); + + let standalone_session = store::Session::new_queued("standalone").with_branch(&branch.id); + store.create_session(&standalone_session).unwrap(); + let standalone = store::Note::new(&branch.id, "Standalone", "top-level body") + .with_session(&standalone_session.id); + store.create_note(&standalone).unwrap(); + + (child_session.id, standalone_session.id) + } + + /// The chat of a note hidden from the branch timeline (a child aggregated + /// under a project note) is read-only; every other session's chat is not. + #[test] + fn hidden_child_note_chat_refuses_user_messages() { + let (store, branch) = setup_branch_store(); + let (child_session_id, standalone_session_id) = + child_and_standalone_note_sessions(&store, &branch); + + assert_eq!( + ensure_session_chat_writable(&store, &child_session_id), + Err(HIDDEN_CHILD_NOTE_CHAT_ERROR.to_string()) + ); + assert_eq!( + ensure_session_chat_writable(&store, &standalone_session_id), + Ok(()) + ); + + // A session with no note at all (a commit session, say) is untouched. + let commit_session = store::Session::new_queued("commit").with_branch(&branch.id); + store.create_session(&commit_session).unwrap(); + assert_eq!( + ensure_session_chat_writable(&store, &commit_session.id), + Ok(()) + ); + } + + /// The gate sits in the shared body of `send_session_message_now`, ahead + /// of the routing on status, so a bypassed composer is refused with the + /// same error on both the desktop and the web path. + #[tokio::test(flavor = "current_thread")] + async fn send_now_refuses_a_hidden_child_note_chat_before_routing() { + let (store, branch) = setup_branch_store(); + let registry = test_registry(); + let (child_session_id, _) = child_and_standalone_note_sessions(&store, &branch); + + let err = send_session_message_now_for_store( + &store, + ®istry, + &child_session_id, + "follow up", + &[], + ) + .await + .unwrap_err(); + assert_eq!(err, HIDDEN_CHILD_NOTE_CHAT_ERROR); + // Nothing was queued or delivered on its behalf. + assert!(store + .list_queued_session_messages(&child_session_id) + .unwrap() + .is_empty()); + } + #[test] fn note_timeline_entries_include_children_annotated_with_their_parent() { let (store, branch) = setup_branch_store(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index c988ed5aa..5d8349af5 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3783,6 +3783,7 @@ pub(crate) async fn dispatch( let content: String = arg(&args, "content")?; let image_ids: Option> = opt_arg(&args, "imageIds")?; let branch_id: Option = opt_arg(&args, "branchId")?; + session_commands::ensure_session_chat_writable(&store, &session_id)?; let message = store .add_queued_session_message( &session_id, @@ -5042,6 +5043,23 @@ mod tests { ); } + /// Queueing a chat message for a hidden child note's session is refused + /// on the web path as on the desktop one: the arm runs the shared gate + /// before the insert. + #[test] + fn web_queue_session_message_gates_hidden_child_note_chats() { + let arm = dispatch_arm_body(include_str!("web_server.rs"), "queue_session_message"); + let gate = arm + .find("session_commands::ensure_session_chat_writable(") + .unwrap_or_else(|| { + panic!("the web `queue_session_message` arm must run the chat gate: {arm}") + }); + let insert = arm + .find("add_queued_session_message(") + .unwrap_or_else(|| panic!("the web `queue_session_message` arm must queue: {arm}")); + assert!(gate < insert, "the gate must run before the insert: {arm}"); + } + /// A web resume goes through the desktop command's body, `prepare_resume` /// included, so it waits out a Stop's backfill and refuses a /// `disconnected` row as the desktop one does, instead of a copy that diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.childNote.component.test.ts b/apps/staged/src/lib/features/sessions/SessionChatPane.childNote.component.test.ts new file mode 100644 index 000000000..d3dfd5f28 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.childNote.component.test.ts @@ -0,0 +1,176 @@ +import { flushSync, mount, tick, unmount } from 'svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BranchNote, Session } from '../../types'; + +const { branchNote } = vi.hoisted(() => ({ + /** What `getBranchNoteBySession` answers for the mounted session. */ + branchNote: { current: null as BranchNote | null, fail: false }, +})); + +// Heavy leaf components are stubbed out: these tests exercise whether the +// pane offers a composer at all, not the composer's internals. +vi.mock('./ChatComposer.svelte', async () => { + const { default: PromptInputStub } = await import('./testStubs/PromptInputStub.svelte'); + return { default: PromptInputStub, composerControlClass: '' }; +}); +vi.mock('../agents/AcpFixedConfigPicker.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock('./PlanCard.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock('./PipelineSteps.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock('./tool-calls/ToolCallCard.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock('./tool-calls/ToolCallHeader.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock('./NoteActivityCard.svelte', () => import('./testStubs/EmptyStub.svelte')); +vi.mock( + '../../shared/markdown/DiagramViewerModal.svelte', + () => import('./testStubs/EmptyStub.svelte') +); +vi.mock('../branches/dragDrop', () => ({ subscribeDragDrop: () => () => {} })); +vi.mock('../agents/agent.svelte', () => ({ agentState: { providers: [] } })); +vi.mock('../settings/preferences.svelte', () => ({ setAcpConfigPref: vi.fn() })); +vi.mock('../../transport', async (importOriginal) => ({ + ...(await importOriginal()), + listenToEvent: () => () => {}, +})); +vi.mock('../../shared/draftCache', () => ({ + createDraftWriteScheduler: () => ({ + scheduleSave: vi.fn(), + saveNow: vi.fn(async () => {}), + clearNow: vi.fn(async () => {}), + flush: vi.fn(async () => {}), + }), + loadDraft: async () => null, +})); +vi.mock('../../commands', async (importOriginal) => ({ + ...(await importOriginal()), + getSession: async (id: string) => + ({ + id, + status: 'completed', + completionReason: 'interrupted', + provider: null, + workingDir: null, + }) as unknown as Session, + getLatestSessionMessages: async () => ({ data: [] }), + getSessionMessages: async () => ({ data: [] }), + getSessionMessagesSince: async () => [], + getSessionAcpMetadataMessages: async () => [], + getSessionAcpMetadataMessagesSince: async () => [], + listQueuedSessionMessages: async () => [], + getBranchNoteBySession: async () => { + if (branchNote.fail) throw new Error('lookup failed'); + return branchNote.current; + }, +})); + +import SessionChatPane from './SessionChatPane.svelte'; +import { reactiveProps } from './testStubs/reactiveProps.svelte'; + +function note(sessionId: string, parentProjectNoteId: string | null): BranchNote { + return { + id: `note-${sessionId}`, + branchId: 'branch-1', + sessionId, + title: 'Note', + content: 'body', + createdAt: 1, + updatedAt: 1, + completedAt: 1, + suggestedNextCommitStep: null, + suggestedNextNoteStep: null, + parentProjectNoteId, + subtype: null, + readAt: null, + }; +} + +function mountPane(sessionId: string) { + const props = reactiveProps({ active: true, sessionId, hashtagItems: [] }); + const target = document.createElement('div'); + document.body.appendChild(target); + const pane = mount(SessionChatPane, { target, props }); + return { pane, props }; +} + +/** Let the pane's async session and note loads and effect flushes settle. */ +async function settle() { + for (let i = 0; i < 8; i++) { + await tick(); + } + flushSync(); +} + +const composer = () => document.querySelector('[data-testid="prompt-input"]'); +const readOnlyNotice = () => document.querySelector('[data-testid="read-only-chat-notice"]'); +const resumeButton = () => + Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Resume'); + +describe('SessionChatPane for a hidden child note', () => { + beforeEach(() => { + document.body.innerHTML = ''; + branchNote.current = null; + branchNote.fail = false; + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('replaces the composer and Resume with a read-only notice', async () => { + branchNote.current = note('child', 'project-note-1'); + const { pane } = mountPane('child'); + flushSync(); + await settle(); + + expect(composer()).toBeNull(); + expect(resumeButton()).toBeUndefined(); + expect(readOnlyNotice()?.textContent).toContain('read-only'); + expect(readOnlyNotice()?.textContent).toContain('project note'); + + unmount(pane); + }); + + it("keeps the composer for a note that is shown in the branch's notes", async () => { + branchNote.current = note('standalone', null); + const { pane } = mountPane('standalone'); + flushSync(); + await settle(); + + expect(composer()).not.toBeNull(); + expect(resumeButton()).toBeDefined(); + expect(readOnlyNotice()).toBeNull(); + + unmount(pane); + }); + + it('keeps the composer when the note lookup fails or finds no note', async () => { + branchNote.fail = true; + const { pane, props } = mountPane('no-note'); + flushSync(); + await settle(); + expect(composer()).not.toBeNull(); + expect(readOnlyNotice()).toBeNull(); + + // A commit session has no note; the chat is writable. + branchNote.fail = false; + props.sessionId = 'commit'; + flushSync(); + await settle(); + expect(composer()).not.toBeNull(); + expect(readOnlyNotice()).toBeNull(); + + unmount(pane); + }); + + it('drops the notice when the pane moves on to a writable session', async () => { + branchNote.current = note('child', 'project-note-1'); + const { pane, props } = mountPane('child'); + flushSync(); + await settle(); + expect(readOnlyNotice()).not.toBeNull(); + + branchNote.current = note('standalone', null); + props.sessionId = 'standalone'; + flushSync(); + await settle(); + expect(readOnlyNotice()).toBeNull(); + expect(composer()).not.toBeNull(); + + unmount(pane); + }); +}); diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index de290ab80..840747826 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -53,6 +53,7 @@ import type { AcpConfigSelection, AcpConfigValueSelection, + BranchNote, Session, SessionBackgroundHoldPayload, SessionMessage, @@ -71,6 +72,7 @@ deleteImage, discoverAcpConfig, getAttachmentThumbnail, + getBranchNoteBySession, getSession, getSessionAcpMetadataMessages, getSessionBackgroundHold, @@ -317,6 +319,10 @@ branchId: string | null; }; + /** Shown in the composer's place for a hidden child note's chat. */ + const CHILD_NOTE_CHAT_READ_ONLY_NOTICE = + "This chat is read-only: its note is part of a project note and isn't shown in the branch's notes."; + let { active, sessionId, @@ -426,6 +432,15 @@ /** The draft's quote blocks, each with the free text before it (see composerDraft.ts). */ let draftQuotes = $state([]); let queuedMessages = $state([]); + /** + * Whether the session's note is a child aggregated under a project note + * (`parentProjectNoteId` set). Such notes are hidden from the branch + * timeline and the project notes list, and the backend refuses new + * messages for their chats, so the composer gives way to a read-only + * notice. Looked up beside the session row; a failed lookup leaves the + * composer in place and the backend's refusal is shown on send. + */ + let childNoteChat = $state(false); let queueActionIds = $state>(new Set()); let copiedId = $state(null); let expandedTools = $state>(new Set()); @@ -1445,6 +1460,7 @@ hasOlderMessages = false; acpMetadataMessages = []; queuedMessages = []; + childNoteChat = false; // A failure line is about the session that was shown when it happened. sendError = null; } @@ -1456,6 +1472,7 @@ statusLoadedForSessionId = null; const statusVersionBeforeFetch = statusEventVersion; const id = sessionId; + void loadChatAccess(id); try { // Only the newest page of the transcript; older pages arrive through // `loadOlderMessages` as the user scrolls up. @@ -1529,6 +1546,18 @@ } } + /** Resolve `childNoteChat` for session `id`; see its doc comment. */ + async function loadChatAccess(id: string) { + let note: BranchNote | null = null; + try { + note = await getBranchNoteBySession(id); + } catch { + note = null; + } + if (closed || sessionId !== id) return; + childNoteChat = note?.parentProjectNoteId != null; + } + /** * Catch a parked pane up without touching `loading`, whose branch would swap * the transcript for a spinner and rebuild every group. One `poll_session` @@ -1968,6 +1997,9 @@ // pane no longer shows. return false; } + // The backend refuses these too; the composer and the Resume/follow-up + // buttons are already gone, so a stray call just does nothing. + if (childNoteChat) return false; if (!sendingLocked && sending) return false; if (!sendingLocked) sending = true; beginComposerAction(); @@ -3706,7 +3738,7 @@ {/each} {/if} - {#if noteFollowupLabel} + {#if noteFollowupLabel && !childNoteChat}
- + {#if !childNoteChat} + + + + {/if} {:else if isReconcileCancelNotice(session.status, session.errorMessage)} @@ -3809,7 +3843,14 @@
- {#if isPipelineRunning && grouped.length === 0} + {#if childNoteChat} + +
+ + {CHILD_NOTE_CHAT_READ_ONLY_NOTICE} +
+ {:else if isPipelineRunning && grouped.length === 0}
@@ -4592,6 +4633,23 @@ /* Same chrome as .chat-composer: the error line (if any) above, the button right-aligned beneath it, and the same bottom inset, as it ends the dialog in the composer's place. */ + /* The composer's slot, when the chat is read-only. */ + .read-only-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + padding-bottom: calc(10px + var(--dialog-safe-bottom, 0px)); + padding-left: calc(14px + var(--dialog-safe-left, 0px)); + padding-right: calc(14px + var(--dialog-safe-right, 0px)); + border-top: 1px solid var(--border-subtle); + background: var(--bg-chrome); + color: var(--text-muted); + font-size: 12px; + line-height: 1.4; + flex-shrink: 0; + } + .pipeline-stop-area { display: flex; flex-direction: column;