Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/split-canvas-conversation-panes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"helmor": minor
---

Split the workspace center column into a multi-pane conversation canvas.

- Drag a conversation onto another's edge to split the center column into side-by-side or stacked panes (up to 4), resize the splits, and rearrange or close panes; the layout persists per workspace and survives navigating away and back. ⌘W closes the focused pane.
- Agents sent from a split now know about their sibling panes: the Helmor system prompt lists the other open sessions and the `helmor` CLI commands to inspect, read, or hand them work, so panes can coordinate across the canvas. Single-pane sends are byte-for-byte unchanged.
7 changes: 7 additions & 0 deletions src-tauri/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ pub struct AgentSendRequest {
/// text — this never alters the wire payload.
#[serde(default)]
pub pasted_texts: Option<Vec<crate::pipeline::types::PastedTextRange>>,
/// Session IDs of the OTHER conversation panes sharing this send's
/// split-canvas (the frontend owns the canvas layout). When non-empty,
/// the Helmor system-prompt preamble gains a "sibling panes" addendum
/// listing them + the `helmor` CLI commands to inspect or message them.
/// Empty/absent ⇒ single-pane behaviour, no addendum.
#[serde(default)]
pub sibling_session_ids: Option<Vec<String>>,
}

#[cfg(test)]
Expand Down
28 changes: 27 additions & 1 deletion src-tauri/src/agents/streaming/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub(super) fn stream_via_sidecar(
.and_then(|(_, _, workspace_id)| workspace_id.as_deref()),
working_directory,
request.permission_mode.as_deref(),
request.sibling_session_ids.as_deref(),
);

// Combine the optional hidden preamble with the user's prompt. Only
Expand Down Expand Up @@ -1468,10 +1469,11 @@ pub(crate) fn build_helmor_system_prompt_for_workspace(
workspace_id: Option<&str>,
working_directory: &std::path::Path,
permission_mode: Option<&str>,
sibling_session_ids: Option<&[String]>,
) -> Option<String> {
use crate::agents::system_prompt::{
build_helmor_chat_prompt, build_helmor_system_prompt, HelmorChatPromptContext,
HelmorSystemPromptContext,
HelmorSystemPromptContext, SiblingSessionInfo,
};

let workspace_id = workspace_id?;
Expand Down Expand Up @@ -1555,6 +1557,29 @@ pub(crate) fn build_helmor_system_prompt_for_workspace(
Ok(Some(ref v)) if v == "true"
);

// Resolve sibling pane titles from the same workspace. The frontend
// sends only the sibling session IDs (the split-canvas layout it owns);
// we look up their titles here so the addendum reads naturally. Unknown
// IDs (e.g. a just-deleted pane) are skipped. Best-effort: a failed
// lookup just elides the addendum rather than failing the send.
let sibling_sessions = match sibling_session_ids {
Some(ids) if !ids.is_empty() => {
let titles: std::collections::HashMap<String, String> =
crate::models::sessions::list_workspace_sessions(workspace_id)
.map(|sessions| sessions.into_iter().map(|s| (s.id, s.title)).collect())
.unwrap_or_default();
ids.iter()
.filter_map(|id| {
titles.get(id).map(|title| SiblingSessionInfo {
id: id.clone(),
title: title.clone(),
})
})
.collect()
}
_ => Vec::new(),
};

let ctx = HelmorSystemPromptContext {
workspace_label,
workspace_root_path: working_directory.display().to_string(),
Expand All @@ -1565,6 +1590,7 @@ pub(crate) fn build_helmor_system_prompt_for_workspace(
stack,
permission_mode: permission_mode.map(str::to_string),
mdx_planning,
sibling_sessions,
};
Some(build_helmor_system_prompt(&ctx))
}
66 changes: 66 additions & 0 deletions src-tauri/src/agents/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ pub struct HelmorSystemPromptContext {
/// MDX plan-authoring contract so the agent writes its plan as an MDX
/// file under `.helmor/plans/` instead of an inline plan.
pub mdx_planning: bool,
/// Other conversation panes sharing this session's split-canvas. Empty
/// elides the entire "sibling panes" addendum (single-pane case). When
/// non-empty, the preamble lists each sibling + the concrete `helmor`
/// CLI commands to inspect or hand it work.
pub sibling_sessions: Vec<SiblingSessionInfo>,
}

/// A sibling conversation pane the current agent can reach via the CLI.
#[derive(Debug, Clone)]
pub struct SiblingSessionInfo {
/// The sibling's Helmor session id (`--session <id>`).
pub id: String,
/// Human-friendly title, shown so the agent can pick the right pane.
pub title: String,
}

/// Lightweight stacked-PR context injected into the workspace preamble so the
Expand Down Expand Up @@ -205,6 +219,22 @@ pub fn build_helmor_system_prompt(ctx: &HelmorSystemPromptContext) -> String {
"\nIf the user asks for help with Helmor itself, point them at the feedback button at the bottom of Helmor's sidebar.\n",
);

if !ctx.sibling_sessions.is_empty() {
let cli = &ctx.cli_command_name;
out.push_str(
"\nYou are one of several conversation panes open together on this workspace. You can inspect or message the sibling panes with the Helmor CLI (already on PATH):\n",
);
for sibling in &ctx.sibling_sessions {
let _ = writeln!(out, " - \"{}\" — session {}", sibling.title, sibling.id);
}
let _ = write!(
out,
"To see which siblings are still working: `{cli} session list --json` (each session's `status` is `idle` or `streaming`).\n\
To read what a sibling did: `{cli} session get-messages --session <id> --json`.\n\
To hand a sibling work: `{cli} send --session <id> \"<prompt>\"` — this BLOCKS until that pane finishes the turn and prints its reply, so there is no need to poll afterwards.\n",
);
}

if ctx.permission_mode.as_deref() == Some("plan") && ctx.mdx_planning {
out.push_str(MDX_PLAN_AUTHORING_BLOCK);
}
Expand Down Expand Up @@ -306,6 +336,7 @@ mod tests {
stack: None,
permission_mode: None,
mdx_planning: false,
sibling_sessions: Vec::new(),
}
}

Expand All @@ -318,6 +349,41 @@ mod tests {
assert!(prompt.contains("`/Users/me/helmor/workspaces/dohooo/feature-x`"));
}

/// Single-pane (no siblings) must NOT render the cross-chat addendum,
/// so existing one-pane behaviour is byte-for-byte unchanged.
#[test]
fn omits_sibling_addendum_when_no_siblings() {
let prompt = build_helmor_system_prompt(&ctx_with_defaults());
assert!(!prompt.contains("several conversation panes"));
}

/// Multi-pane: the addendum lists each sibling and the concrete CLI
/// commands, and makes the blocking nature of `send` explicit (no poll).
#[test]
fn renders_sibling_addendum_with_titles_and_commands() {
let mut ctx = ctx_with_defaults();
ctx.sibling_sessions = vec![
SiblingSessionInfo {
id: "a1b2".to_string(),
title: "Tests pane".to_string(),
},
SiblingSessionInfo {
id: "c3d4".to_string(),
title: "Docs pane".to_string(),
},
];
let prompt = build_helmor_system_prompt(&ctx);
assert!(prompt.contains("several conversation panes"));
assert!(prompt.contains("\"Tests pane\" — session a1b2"));
assert!(prompt.contains("\"Docs pane\" — session c3d4"));
assert!(prompt.contains("session get-messages --session <id>"));
assert!(prompt.contains("send --session <id>"));
// The blocking contract must be explicit so the agent doesn't poll.
assert!(prompt.contains("BLOCKS"));
// Addendum stays inside the helmor_context envelope.
assert!(prompt.trim_end().ends_with("</helmor_context>"));
}

/// Resolved target + base branch → the diff/PR commands are
/// pre-substituted with the real branch names. This is the load-
/// bearing line the agent uses to decide where to base PRs.
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ pub fn send_message(
Some(&workspace_id),
std::path::Path::new(&cwd),
params.permission_mode.as_deref(),
// CLI-launched sends carry no split-canvas, so no sibling addendum.
None,
);
let wire_prompt = match helmor_prefix.as_deref() {
Some(helmor) => format!("{helmor}\n\nUser request:\n{}", params.prompt),
Expand Down
11 changes: 11 additions & 0 deletions src/features/conversation/hooks/use-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ type UseConversationStreamingArgs = {
getSessionContextReferences?: (
sessionId: string,
) => readonly SessionContextReference[];
/** Session IDs of the OTHER split-canvas panes open alongside this one.
* Stable getter (mirrors `getSessionContextReferences`) so it can ride
* the send without churning the submit callback's identity. Sent to the
* backend as `siblingSessionIds`, which drives the cross-chat addendum in
* the agent's system prompt. Absent/empty ⇒ single-pane, no addendum. */
getSiblingSessionIds?: (sessionId: string) => readonly string[];
onInteractionSessionsChange?: (
sessionWorkspaceMap: Map<string, string>,
interactionCounts: Map<string, number>,
Expand All @@ -160,6 +166,7 @@ export function useConversationStreaming({
submitQueue,
activeStreams,
getSessionContextReferences,
getSiblingSessionIds,
onInteractionSessionsChange,
onSessionCompleted,
onSessionAborted,
Expand Down Expand Up @@ -972,6 +979,7 @@ export function useConversationStreaming({
const { flushStreamMessages, scheduleFlush } = flushers;
cleanup = flushers.cleanup;

const siblingSessionIds = getSiblingSessionIds?.(targetSessionId) ?? [];
await startAgentMessageStream(
{
provider: model.provider,
Expand All @@ -988,6 +996,8 @@ export function useConversationStreaming({
files: filePaths,
images: imagePaths,
pastedTexts: pastedTexts.length > 0 ? pastedTexts : null,
siblingSessionIds:
siblingSessionIds.length > 0 ? [...siblingSessionIds] : null,
},
createStreamEventDispatcher({
contextKey,
Expand Down Expand Up @@ -1079,6 +1089,7 @@ export function useConversationStreaming({
displayedSessionId,
displayedWorkspaceId,
getSessionContextReferences,
getSiblingSessionIds,
invalidateConversationQueries,
markSendingState,
pushToast,
Expand Down
26 changes: 26 additions & 0 deletions src/features/conversation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { WorkspaceComposerContainer } from "@/features/composer/container";
import type { StartSubmitMode } from "@/features/composer/start-submit-mode";
import type { UserInputResponseHandler } from "@/features/composer/user-input";
import { WorkspacePanelContainer } from "@/features/panel/container";
import type { CanvasGroupTab } from "@/features/panel/header";
import { FileLinkProvider } from "@/features/panel/message-components/file-link-context";
import type { SessionCloseRequest } from "@/features/panel/use-confirm-session-close";
import {
Expand Down Expand Up @@ -137,6 +138,21 @@ export type WorkspaceConversationContainerProps = {
contextPreviewActive?: boolean;
onSelectContextPreview?: () => void;
onCloseContextPreview?: () => void;
/** Split-canvas: stable getter returning the session IDs of the OTHER
* panes open alongside this one. Forwarded to `useConversationStreaming`
* so a send carries its siblings to the backend system-prompt builder.
* Omitted in the single-pane case. */
getSiblingSessionIds?: (sessionId: string) => readonly string[];
/** Split-canvas: a pane hides its own header tab strip (the canvas renders
* one shared tab bar above all panes instead). */
hideHeader?: boolean;
/** Split-canvas: collapse the listed sessions into a single "split" tab in
* this conversation's header (used while viewing a non-split session so the
* user can click back into the persisted split). */
canvasGroup?: CanvasGroupTab | null;
/** Split-canvas: split the current conversation from its header. */
onCanvasSplit?: (direction: "row" | "col") => void;
canvasSplitDisabled?: boolean;
/** Prompt queued by an external caller (e.g. the inspector Git commit
* button or a drained CLI send) to be auto-submitted once the displayed
* session matches. Per-session config (model / effort / fast-mode /
Expand Down Expand Up @@ -231,6 +247,11 @@ export const WorkspaceConversationContainer = memo(
contextPreviewActive = false,
onSelectContextPreview,
onCloseContextPreview,
getSiblingSessionIds,
hideHeader = false,
canvasGroup = null,
onCanvasSplit,
canvasSplitDisabled = false,
pendingPromptForSession = null,
pendingCreatedWorkspaceSubmit = null,
onPendingCreatedWorkspaceSubmitConsumed,
Expand Down Expand Up @@ -433,6 +454,7 @@ export const WorkspaceConversationContainer = memo(
submitQueue: submitQueueApi,
activeStreams,
getSessionContextReferences,
getSiblingSessionIds,
onInteractionSessionsChange,
onSessionCompleted,
onSessionAborted,
Expand Down Expand Up @@ -846,6 +868,10 @@ export const WorkspaceConversationContainer = memo(
displayedWorkspaceId={displayedWorkspaceId}
selectedSessionId={selectedSessionId}
displayedSessionId={displayedSessionId}
hideHeader={hideHeader}
canvasGroup={canvasGroup}
onCanvasSplit={onCanvasSplit}
canvasSplitDisabled={canvasSplitDisabled}
sessionSelectionHistory={sessionSelectionHistory}
sending={sendingForPanel}
busySessionIds={panelBusySessionIds}
Expand Down
31 changes: 30 additions & 1 deletion src/features/panel/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type WorkspaceScriptType,
} from "@/lib/workspace-script-actions";
import { publishShellEvent } from "@/shell/event-bus";
import type { CanvasGroupTab } from "./header";
import { WorkspacePanel } from "./index";
import type { SessionCloseRequest } from "./use-confirm-session-close";

Expand Down Expand Up @@ -79,6 +80,17 @@ type WorkspacePanelContainerProps = {
* before the real send actually fires, swapped out as soon as the real
* user message lands in DB. */
optimisticPendingSubmit?: OptimisticPendingSubmit | null;
/** Split-canvas: collapse the listed sessions into one "split" tab. */
canvasGroup?: CanvasGroupTab | null;
/** Split-canvas: split the current conversation (header control). */
onCanvasSplit?: (direction: "row" | "col") => void;
canvasSplitDisabled?: boolean;
/** Render ONLY the header — used as the single shared tab bar above a
* multi-pane canvas. Suppresses the body + workspace-level side effects
* (auto-create, plan surfacing) that the panes themselves own. */
headerOnly?: boolean;
/** Render the body but NOT the header (each canvas pane). */
hideHeader?: boolean;
};

export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
Expand All @@ -104,6 +116,11 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
headerActions,
headerLeading,
optimisticPendingSubmit = null,
canvasGroup = null,
onCanvasSplit,
canvasSplitDisabled = false,
headerOnly = false,
hideHeader = false,
}: WorkspacePanelContainerProps) {
const queryClient = useQueryClient();
const { settings } = useSettings();
Expand Down Expand Up @@ -138,6 +155,10 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
const autoCreatingWorkspaceRef = useRef<Set<string>>(new Set());

useEffect(() => {
// Header-only host doesn't own workspace lifecycle — the panes do.
if (headerOnly) {
return;
}
if (!displayedWorkspaceId || selectedWorkspaceId !== displayedWorkspaceId) {
return;
}
Expand Down Expand Up @@ -260,6 +281,7 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
cancelled = true;
};
}, [
headerOnly,
displayedWorkspaceId,
detailQuery.isFetchedAfterMount,
queryClient,
Expand Down Expand Up @@ -354,7 +376,9 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
// Selecting a plan tab is local panel state — distinct from the session
// selection so switching back to a session is a single click.
const planListQuery = usePlanList(
settings.mdxPlanningEnabled ? threadSessionId : null,
// The header-only host renders no thread, so it owns no plan surface —
// the panes do. Skip plan surfacing here to avoid a duplicate watcher.
!headerOnly && settings.mdxPlanningEnabled ? threadSessionId : null,
);
// The plan surface shows AT MOST ONE plan at a time. Opening another plan
// SWAPS it into the same single tab rather than stacking a new one, so there
Expand Down Expand Up @@ -818,6 +842,11 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({
missingScriptTypes={missingScriptTypes}
onInitializeScript={handleInitializeScript}
changeRequest={workspaceChangeRequest}
canvasGroup={canvasGroup}
onCanvasSplit={onCanvasSplit}
canvasSplitDisabled={canvasSplitDisabled}
headerOnly={headerOnly}
hideHeader={hideHeader}
/>
);
});
Loading
Loading