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
51 changes: 40 additions & 11 deletions apps/desktop/electron/main/ipc/agent-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FinishTurn } from "../runtime/plans";
import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime";
import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth";
import { appendPromptFallbackPaths, durableUserMessageId, preparePromptAttachments, type PreparedPromptAttachment } from "../prompt-attachments";
import { resolveSessionReferences } from "../session-references";
import { executionFromResponse, resolveSessionMessageInput } from "@pi-desktop/host-runtime";
import type { AgentExtensionBridge } from "../agent-extensions";
import type { AgentHostBridge } from "../agent-host-bridge";
Expand Down Expand Up @@ -542,6 +543,19 @@ export function registerAgentIpc({
});
throw error;
}
// A `pi-desktop://session/<id>` link in the draft becomes a bounded excerpt
// that travels with this message from now on (issue #1324). A skipped link
// stays plain text; nothing else about the prompt changes.
const sessionReferences = await resolveSessionReferences({
host,
logger,
sessionId: req.sessionId,
projectPath:
typeof session.projectPath === "string" && session.projectPath.trim()
? session.projectPath.trim()
: undefined,
content: req.content,
});
const modelContent = appendPromptFallbackPaths(
promptContent,
preparedAttachments,
Expand All @@ -568,8 +582,13 @@ export function registerAgentIpc({
...(sessionMessage ? { sessionMessage: sessionMessage.origin } : {}),
createdAt: new Date().toISOString(),
status: "complete" as const,
...(preparedAttachments.length
? { attachments: preparedAttachments.map((attachment) => attachment.message) }
...(preparedAttachments.length || sessionReferences.length
? {
attachments: [
...preparedAttachments.map((attachment) => attachment.message),
...sessionReferences,
],
}
: {}),
...(voiceOrigin ? { voiceOrigin } : {}),
...(slashCommand ? { command: slashCommand } : {}),
Expand Down Expand Up @@ -623,16 +642,26 @@ export function registerAgentIpc({
turnId: durableTurnId,
content: modelContent,
...(sessionMessage ? { sessionMessage: sessionMessage.origin } : {}),
attachments: preparedAttachments
.filter((attachment) => attachment.inlineData)
.map((attachment) => ({
path: attachment.message.ref,
name: attachment.message.name,
kind: attachment.message.kind,
mimeType: attachment.message.mimeType,
size: attachment.message.size,
data: attachment.inlineData,
attachments: [
...preparedAttachments
.filter((attachment) => attachment.inlineData)
.map((attachment) => ({
path: attachment.message.ref,
name: attachment.message.name,
kind: attachment.message.kind,
mimeType: attachment.message.mimeType,
size: attachment.message.size,
data: attachment.inlineData,
})),
// A referenced conversation crosses the sidecar as quoted text for
// this turn; the durable record above keeps it for later turns.
...sessionReferences.map((attachment) => ({
path: attachment.ref,
name: attachment.name,
kind: attachment.kind,
text: attachment.text,
})),
],
userMessageId: userMessage.id,
// Per-turn permission ceiling override (R1 leftover; spec §7.3). The
// sidecar records it on the turn context; enforcement of a NARROWER
Expand Down
134 changes: 134 additions & 0 deletions apps/desktop/electron/main/session-references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Session references: `pi-desktop://session/<id>` links a Composer draft carries.
*
* The link is the whole interaction — no new Composer trigger symbol — and the
* transcript shows it as a card next to the user's words. Electron main is the
* only writer: it reads the referenced conversation through the host, refuses
* what this conversation may not pull, and attaches a bounded excerpt that
* travels with the message from then on.
*/
import { parseSessionLinks, type MessageAttachment } from "@pi-desktop/shared";
import type { HostProcess } from "./host-process";
import type { Logger } from "./logger";

/** Newest messages read from the referenced conversation. */
export const SESSION_REFERENCE_MESSAGE_LIMIT = 40;
/** Per-field cap for one referenced message. */
export const SESSION_REFERENCE_CONTENT_LIMIT = 8 * 1024;
/** Whole excerpt cap; the model gets a bounded quotation, never a whole session. */
export const SESSION_REFERENCE_TOTAL_LIMIT = 16 * 1024;

type ReferencedMessage = {
role?: string;
content?: string;
toolName?: string;
isError?: boolean;
};

type ReferencedSession = {
id?: string;
title?: string;
projectPath?: string;
messages?: ReferencedMessage[];
};

/** The words the model sees for one referenced conversation. */
export function formatSessionExcerpt(
session: ReferencedSession,
limit = SESSION_REFERENCE_TOTAL_LIMIT,
): string {
const title = (session.title || session.id || "").trim();
const lines: string[] = [];
const messages = [...(session.messages ?? [])].reverse();
let used = 0;
let omitted = 0;
for (const message of messages) {
const text = (message.content ?? "").trim();
if (!text) continue;
const role = message.toolName
? `tool ${message.toolName}`
: message.role === "assistant"
? "assistant"
: "user";
const line = `${role}: ${text}`;
if (used + line.length > limit) {
omitted += 1;
continue;
}
used += line.length;
lines.unshift(line);
}
if (!lines.length) return "";
const head = [
`Referenced conversation "${title}" (${session.id ?? "unknown"}).`,
`${lines.length} message${lines.length === 1 ? "" : "s"} shown in order, newest last` +
(omitted ? `, ${omitted} earlier message${omitted === 1 ? "" : "s"} omitted for length.` : "."),
].join(" ");
return [head, "", ...lines].join("\n");
}

/** The project a path belongs to, as the host compares it. */
function canonicalProject(path: string | undefined): string {
return (path ?? "").replace(/[\\/]+$/, "");
}

/**
* Resolves every session link in one prompt into an attachment record. Skipped
* references stay plain text in the message: the current conversation is never
* referenced, and another project's transcript is not this turn's context.
*/
export async function resolveSessionReferences({
host,
logger,
sessionId,
projectPath,
content,
}: {
host: HostProcess;
logger: Pick<Logger, "app">;
sessionId: string;
projectPath?: string;
content: string;
}): Promise<MessageAttachment[]> {
const ids = parseSessionLinks(content).filter((id) => id !== sessionId);
if (!ids.length) return [];
const attachments: MessageAttachment[] = [];
for (const id of ids) {
try {
const result = await host.call<{ session?: ReferencedSession }>("session.get", {
id,
messageLimit: SESSION_REFERENCE_MESSAGE_LIMIT,
contentLimit: SESSION_REFERENCE_CONTENT_LIMIT,
});
const referenced = result.session;
if (!referenced) {
logger.app("session", "warn", "session reference not found", {
sessionId,
data: id,
});
continue;
}
if (canonicalProject(referenced.projectPath) !== canonicalProject(projectPath)) {
logger.app("session", "warn", "session reference outside this project was skipped", {
sessionId,
data: id,
});
continue;
}
const text = formatSessionExcerpt(referenced);
if (!text) continue;
attachments.push({
kind: "session",
name: (referenced.title || id).trim(),
ref: id,
text,
});
} catch (error) {
logger.app("session", "warn", "session reference read failed", {
sessionId,
data: `${id}: ${String(error)}`,
});
}
}
return attachments;
}
25 changes: 24 additions & 1 deletion apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
sidebarSessionStatus,
type SidebarSessionStatus,
} from "../lib/sidebar-session-status";
import { ErrorCodes } from "@pi-desktop/shared";
import { ErrorCodes, formatSessionLink } from "@pi-desktop/shared";
import type { SessionSummary } from "@pi-desktop/shared";
import type {
ProjectMeta,
Expand Down Expand Up @@ -1439,6 +1439,20 @@ export function Sidebar({
closeMenus();
};

/**
* The link another conversation references: pasting it into a Composer draft
* carries a bounded excerpt of this conversation into that turn (issue #1324).
*/
const copySessionLink = async (session: SessionSummary) => {
try {
await navigator.clipboard.writeText(formatSessionLink(session.id));
showToast(t("chat.copied"));
} catch (error) {
reportError(error);
}
closeMenus();
};

const openSessionPath = async (session: SessionSummary) => {
closeMenus(false);
try {
Expand Down Expand Up @@ -2178,6 +2192,15 @@ export function Sidebar({
{t("nav.createBranch")}
</button>
) : null}
<button
type="button"
role="menuitem"
data-action="copy-session-link"
onClick={() => void copySessionLink(session)}
>
<IconCopy size={14} />
{t("nav.copySessionLink")}
</button>
{settings?.developerMode === true ? (
<>
<button
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/features/chat/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
LinkifiedText,
MessageAttachmentImage,
MessageTimestamp,
SessionRefChip,
} from "./shared";
import {
useChatTextActions,
Expand Down Expand Up @@ -264,6 +265,10 @@ export const MessageRow = memo(function MessageRow({
attachment={attachment}
onOpenFile={openFileRef}
/>
) : attachment.kind === "session" ? (
<span key={`${attachment.ref}:${attachment.name}`} role="listitem">
<SessionRefChip attachment={attachment} />
</span>
) : (
<span
key={`${attachment.ref}:${attachment.name}`}
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
IconAudio,
IconBot,
IconBranch,
IconChat,
IconCheck,
IconChevronRight,
IconCircleAlert,
Expand Down Expand Up @@ -455,6 +456,42 @@ export function FileRefChip({
);
}

/**
* A referenced conversation on a user message (issue #1324). The draft carried
* a `pi-desktop://session/<id>` link; main attached a bounded excerpt for the
* model and this chip is how the reader sees and reopens it.
*/
export function SessionRefChip({ attachment }: { attachment: MessageAttachment }) {
const { t } = useTranslation();
const selectSession = useAppStore((state) => state.selectSession);
// The chip names a conversation, not the name that conversation carried when
// the link was pasted: a rename — manual, or the first-turn summary — follows
// through to every message that references it. The recorded name is what the
// model block quotes, and it stays the fallback for a conversation this
// viewer no longer lists.
const liveTitle = useAppStore(
(state) => state.sessions.find((session) => session.id === attachment.ref)?.title,
);
const name = (liveTitle ?? "").trim() || attachment.name;
const label = `${t("chat.sessionReference")} · ${name}`;
return (
<button
type="button"
className="composer-chip chat-file-chip"
data-action="open-session-reference"
data-session-id={attachment.ref}
title={t("chat.sessionReferenceOpen", { title: name })}
aria-label={label}
onClick={() => void selectSession(attachment.ref).catch(() => undefined)}
>
<span className="composer-chip-icon" aria-hidden>
<IconChat size={13} />
</span>
<span className="composer-chip-name">{label}</span>
</button>
);
}

/**
* User-message image attachment as a thumbnail. The host resolves the ref
* into a bounded data URL; an unresolvable load falls back to the file chip.
Expand Down
20 changes: 12 additions & 8 deletions apps/desktop/src/stores/helpers/store-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
SessionSummary,
UiMessage,
} from "@pi-desktop/shared";
import { modeForProposalKind } from "@pi-desktop/shared";
import { isRenderableAttachment, modeForProposalKind } from "@pi-desktop/shared";
import type { ComposerDraftSnapshot } from "../../lib/composer-smart-stop";
import { normalizeProjectPath } from "../../lib/sidebar-session-groups";
import {
Expand Down Expand Up @@ -48,13 +48,17 @@ export function promptAttachmentsFromDraft(
export function promptAttachmentsFromMessage(
attachments: UiMessage["attachments"],
): AgentPromptAttachment[] {
return (attachments ?? []).map((attachment) => ({
path: attachment.ref,
name: attachment.name,
kind: attachment.kind,
...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}),
...(attachment.size !== undefined ? { size: attachment.size } : {}),
}));
// A session reference is not a renderer-resolvable attachment: its link text
// is still in the message, so main re-resolves it on the next send.
return (attachments ?? [])
.filter(isRenderableAttachment)
.map((attachment) => ({
path: attachment.ref,
name: attachment.name,
kind: attachment.kind,
...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}),
...(attachment.size !== undefined ? { size: attachment.size } : {}),
}));
}

export function withoutRecordKey<T>(record: Record<string, T>, key: string): Record<string, T> {
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/stores/slices/transcript-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import i18n from "i18next";
import type {
UiMessage,
} from "@pi-desktop/shared";
import { isRenderableAttachment } from "@pi-desktop/shared";
import { api } from "../../lib/api";
import { resolveComposerSmartStop } from "../../lib/composer-smart-stop";
import {
Expand Down Expand Up @@ -143,14 +144,16 @@ export function createTranscriptSlice({
const optimisticMessage = optimisticUserMessage(
crypto.randomUUID(),
prompt,
(attachments ?? state.messages[userIndex].attachments ?? []).map(
(attachment) => ({
(attachments ?? state.messages[userIndex].attachments ?? [])
// A session reference re-resolves from its link text on send; it is
// not an optimistic file reference.
.filter(isRenderableAttachment)
.map((attachment) => ({
path: attachment.ref,
name: attachment.name,
kind: attachment.kind,
mimeType: attachment.mimeType,
}),
),
})),
);

set((current) => ({
Expand Down
Loading
Loading