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
2 changes: 1 addition & 1 deletion apps/desktop/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains
- NativePi is a Windows desktop application used alongside local code projects, Git repositories, and the existing Pi CLI ecosystem. Its workspace can be shared temporarily to browsers on the same local network.
- Users pin project folders; create, discover, import, and manage Pi sessions; inspect streamed messages and tool activity; and review Git state and diffs.
- Existing Pi credentials, configuration, sessions, packages, skills, prompts, and extensions remain in Pi's normal storage and remain usable by the Pi CLI.
- NativePi keeps at most one Pi process per project. Different projects may run concurrently.
- NativePi keeps one Pi process per active chat, so chats in the same project and different projects may run concurrently.
- Conversations open directly from Pi session files without waiting for a Pi process to start. Drafts remain editable and are restored if a cold send fails.
- Narrow windows move project navigation and project context into sheets so the conversation and composer remain usable.

Expand Down
186 changes: 103 additions & 83 deletions apps/desktop/src/main/ipc.ts

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions apps/desktop/src/renderer/components/ComposerAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ export function useComposerAutocomplete(
const [dismissed, setDismissed] = useState<number | null>(null);
// The characters an extension asked for, and the line it needs to answer about.
const extensionTriggers = useAppStore((s) => s.extTriggers);
const sessionFile = useAppStore((s) => s.activeSessionFile);
const [line, setLine] = useState<{ text: string; caret: number } | null>(null);
const { commands, skills, files, loading } = useCompletionData(projectPath, trigger?.kind ?? null);
const extension = useExtensionCompletions(projectPath, trigger?.kind === "extension" ? line : null);
const extension = useExtensionCompletions(projectPath, sessionFile, trigger?.kind === "extension" ? line : null);

const open = trigger !== null && dismissed !== trigger.start;
const options = !open
Expand Down Expand Up @@ -234,7 +235,7 @@ function useCompletionData(projectPath: string | null, kind: TriggerKind | null)
* composer's own triggers carry on working, which is what happens in the
* terminal too when a provider declines to answer.
*/
function useExtensionCompletions(projectPath: string | null, line: { text: string; caret: number } | null) {
function useExtensionCompletions(projectPath: string | null, sessionFile: string | null, line: { text: string; caret: number } | null) {
const [state, setState] = useState<{ options: ExtensionCompletionOption[]; loading: boolean }>(NO_EXTENSION_OPTIONS);

useEffect(() => {
Expand All @@ -247,7 +248,7 @@ function useExtensionCompletions(projectPath: string | null, line: { text: strin
const timer = window.setTimeout(() => {
const { lines, cursorLine, cursorCol } = linesAt(line.text, line.caret);
void rpc.request
.tuiComplete({ projectDir: projectPath, lines, cursorLine, cursorCol })
.tuiComplete({ projectDir: projectPath, sessionFile, lines, cursorLine, cursorCol })
.then(({ completions }) => {
if (cancelled) return;
setState({
Expand All @@ -271,7 +272,7 @@ function useExtensionCompletions(projectPath: string | null, line: { text: strin
cancelled = true;
window.clearTimeout(timer);
};
}, [line, projectPath]);
}, [line, projectPath, sessionFile]);

return state;
}
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/renderer/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerComposerInserter } from "../lib/composerInsert.ts";
import { AutocompleteMenu, useComposerAutocomplete } from "./ComposerAutocomplete.tsx";
import { cn } from "@/lib/utils.ts";
import { rpc } from "@/lib/rpc.ts";
import { useAppStore } from "../lib/store.ts";

/**
* The message field: prose with skill and file chips in it.
Expand Down Expand Up @@ -62,7 +63,7 @@ export default function ComposerInput({
// the text — that is the point of `applyCompletion` — so the edit is asked
// for rather than assumed. Everything else is inserted here.
if (option.kind === "extension") {
void applyExtensionCompletion(element, projectPath, option, emit);
void applyExtensionCompletion(element, projectPath, useAppStore.getState().activeSessionFile, option, emit);
return;
}
// A command stays editable text — the user types its arguments next — where
Expand Down Expand Up @@ -199,6 +200,7 @@ export default function ComposerInput({
async function applyExtensionCompletion(
element: HTMLElement,
projectPath: string | null,
sessionFile: string | null,
option: ExtensionCompletionOption,
emit: () => void,
): Promise<void> {
Expand All @@ -211,6 +213,7 @@ async function applyExtensionCompletion(
const reply = await rpc.request
.tuiApply({
projectDir: projectPath,
sessionFile,
lines,
cursorLine,
cursorCol,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function Sidebar({ onClose, overlay = false }: { onClose: () => v
const selectProject = useAppStore((s) => s.selectProject);
const removeProject = useAppStore((s) => s.removeProject);
const projectBusyStates = useAppStore(
useShallow((s) => s.projects.map((project) => s.conversations[project.path]?.running ?? false)),
useShallow((s) => s.projects.map((project) => Object.values(s.conversations).some((conversation) => conversation.projectDir === project.path && conversation.running))),
);
const importSession = useAppStore((s) => s.importSession);
const refreshSessions = useAppStore((s) => s.refreshSessions);
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/renderer/components/TuiSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function useSurfaceTerminal(
) {
const containerRef = useRef<HTMLDivElement>(null);
const fontSize = useAppStore((s) => s.preferences.terminalFontSize);
const sessionFile = useAppStore((s) => s.activeSessionFile);
const { rows, focus } = options;

useEffect(() => {
Expand Down Expand Up @@ -73,6 +74,7 @@ function useSurfaceTerminal(
if (!projectDir) return;
void rpc.request.tuiSend({
projectDir,
sessionFile,
frame: { type: "nativepi_tui_input", surfaceId: surface.id, data },
});
});
Expand All @@ -95,6 +97,7 @@ function useSurfaceTerminal(
if (!projectDir) return;
void rpc.request.tuiSend({
projectDir,
sessionFile,
frame: {
type: "nativepi_tui_resize",
surfaceId: surface.id,
Expand All @@ -117,7 +120,7 @@ function useSurfaceTerminal(
offWrite();
terminal.dispose();
};
}, [focus, fontSize, projectDir, rows, surface.id]);
}, [focus, fontSize, projectDir, rows, sessionFile, surface.id]);

return containerRef;
}
Expand Down
63 changes: 36 additions & 27 deletions apps/desktop/src/renderer/lib/store/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const MAX_REMOTE_IMAGE_BATCH_BYTES = 48 * 1024 * 1024;
* answered with the chat the user just left until they touched the new one.
*/
function reportActiveDraft(get: GetState): void {
reportDraft(get().activeProjectPath, get().drafts[draftKey(get)] ?? "");
reportDraft(get().activeProjectPath, get().activeSessionFile, get().drafts[draftKey(get)] ?? "");
}

/**
Expand Down Expand Up @@ -112,14 +112,14 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
// A run already streaming into this chat keeps its live state — this is
// the path back into a project that kept working in the background, and
// its conversation has been fed every event in the meantime.
const conv = get().conversations[projectPath];
if (conv && conv.sessionFile === sessionFile && (conv.running || conv.error !== undefined)) return;
patchConversation(set, projectPath, () => ({ ...emptyConversation(), sessionFile }));
const conv = get().conversations[sessionFile];
if (conv && (conv.running || conv.error !== undefined)) return;
patchConversation(set, projectPath, sessionFile, () => ({ ...emptyConversation(), projectDir: projectPath, sessionFile }));
}
const { entries } = await rpc.request.readSession({ sessionFile });
if (get().activeSessionFile !== sessionFile || get().activeProjectPath !== projectPath) return;
if (projectPath) {
patchConversation(set, projectPath, {
patchConversation(set, projectPath, sessionFile, {
entries: entries.filter((e): e is SessionEntry => e.type !== "session"),
sessionName: sessionInfoName(entries),
});
Expand All @@ -130,7 +130,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
const projectDir = get().activeProjectPath;
if (projectDir) {
void rpc.request.watchSession({ projectDir, sessionFile: null });
patchConversation(set, projectDir, () => emptyConversation());
patchConversation(set, projectDir, null, () => ({ ...emptyConversation(), projectDir }));
}
set({ activeSessionFile: null, isNewChat: true });
reportActiveDraft(get);
Expand All @@ -139,10 +139,11 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
importSession: async (targetProjectDir, sourceFile) => {
const projectDir = targetProjectDir ?? get().activeProjectPath;
if (!projectDir) return false;
const targetSessionFile = get().activeProjectPath === projectDir ? get().activeSessionFile : null;
const res = await rpc.request.importSession({ projectDir, sourceFile });
if (res.canceled) return false;
if (!res.ok || !res.sessionFile) {
patchConversation(set, projectDir, { error: res.error ?? "Failed to import chat" });
patchConversation(set, projectDir, targetSessionFile, { error: res.error ?? "Failed to import chat" });
return false;
}
await get().refreshSessions(projectDir);
Expand All @@ -156,7 +157,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
const key = draftKey(get);
set((s) => ({ drafts: { ...s.drafts, [key]: text } }));
persist(get);
reportDraft(get().activeProjectPath, text);
reportDraft(get().activeProjectPath, get().activeSessionFile, text);
},

insertIntoComposer: (text) => {
Expand Down Expand Up @@ -246,7 +247,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
* whatever it does show up as arrives as events, a moment later.
*/
const pendingEntry: PendingMessage | null = text.startsWith("/") ? null : { id: pendingId++, text, images };
patchConversation(set, projectDir, (c) => ({
patchConversation(set, projectDir, s.activeSessionFile, (c) => ({
pending: pendingEntry ? [...c.pending, pendingEntry] : c.pending,
error: undefined,
errorRecovery: undefined,
Expand All @@ -262,7 +263,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
images: images.map(toImageContent),
});
if (!res.ok) {
patchConversation(set, projectDir, (c) => ({
patchConversation(set, projectDir, s.activeSessionFile, (c) => ({
pending: c.pending.filter((p) => p.id !== pendingEntry?.id),
error: res.error ?? "Failed to send",
errorRecovery: "retrySend",
Expand All @@ -277,7 +278,13 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
return;
}
if (res.sessionFile) {
patchConversation(set, projectDir, { sessionFile: res.sessionFile });
patchConversation(set, projectDir, res.sessionFile, { projectDir, sessionFile: res.sessionFile });
if (!s.activeSessionFile) {
set((state) => {
const { [projectDir]: draft, ...conversations } = state.conversations;
return { conversations: { ...conversations, [res.sessionFile!]: { ...(draft ?? emptyConversation()), projectDir, sessionFile: res.sessionFile! } } };
});
}
setLastChat(projectDir, res.sessionFile);
persist(get);
if (get().activeProjectPath === projectDir && get().activeSessionFile !== res.sessionFile) {
Expand All @@ -291,11 +298,12 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
enqueue: async (behavior) => {
const draft = currentDraft(get);
if (!draft) return;
const { projectDir, key, text, images } = draft;
const { state: sendState, projectDir, key, text, images } = draft;
const sessionFile = sendState.activeSessionFile;

// No optimistic entry: Pi echoes the queued message back via queue_update,
// which is the source of truth for what's pending.
patchConversation(set, projectDir, { error: undefined });
patchConversation(set, projectDir, sessionFile, { error: undefined });
get().setDraft("");
clearAttachments(set, key);

Expand All @@ -311,14 +319,14 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
const res = text.startsWith("/")
? await rpc.request.submit({
projectDir,
sessionFile: get().activeSessionFile,
sessionFile,
message: text,
images: images.map(toImageContent),
streamingBehavior: behavior,
})
: await rpc.request.enqueue({ projectDir, behavior, message: text, images: images.map(toImageContent) });
: await rpc.request.enqueue({ projectDir, sessionFile: sessionFile!, behavior, message: text, images: images.map(toImageContent) });
if (!res.ok) {
patchConversation(set, projectDir, {
patchConversation(set, projectDir, sessionFile, {
error: res.error ?? "Failed to queue message",
errorRecovery: "retrySend",
});
Expand All @@ -329,12 +337,14 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({

abort: () => {
const projectDir = get().activeProjectPath;
if (projectDir) void rpc.request.abort({ projectDir });
const sessionFile = get().activeSessionFile;
if (projectDir && sessionFile) void rpc.request.abort({ projectDir, sessionFile });
},

abortRetry: () => {
const projectDir = get().activeProjectPath;
if (projectDir) void rpc.request.abortRetry({ projectDir });
const sessionFile = get().activeSessionFile;
if (projectDir && sessionFile) void rpc.request.abortRetry({ projectDir, sessionFile });
},

renameChat: async (sessionFile, name) => {
Expand All @@ -344,7 +354,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
if (res.ok) {
await get().refreshSessions(projectDir);
if (get().activeSessionFile === sessionFile) {
patchConversation(set, projectDir, { sessionName: name });
patchConversation(set, projectDir, sessionFile, { sessionName: name });
}
}
return res;
Expand Down Expand Up @@ -409,14 +419,14 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
const sessionFile = get().activeSessionFile;
const projectDir = get().activeProjectPath;
if (!sessionFile) return;
if (projectDir) patchConversation(set, projectDir, { externalChange: null });
if (projectDir) patchConversation(set, projectDir, sessionFile, { externalChange: null });
await get().selectChat(sessionFile);
if (projectDir && get().activeProjectPath === projectDir) await get().refreshSessions(projectDir);
},

clearError: () => {
const projectDir = get().activeProjectPath;
if (projectDir) patchConversation(set, projectDir, { error: undefined, errorRecovery: undefined });
if (projectDir) patchConversation(set, projectDir, get().activeSessionFile, { error: undefined, errorRecovery: undefined });
},

onEvent: ({ projectDir, sessionFile, event }) => {
Expand All @@ -437,8 +447,7 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
// a run keeps its state — and its transcript — while another project is on
// screen. Events for a chat other than the one this runtime belongs to are
// still dropped, exactly as before.
const conv = conversationFor(s, projectDir);
if (sessionFile && conv.sessionFile && sessionFile !== conv.sessionFile) return;
const conv = conversationFor(s, projectDir, sessionFile ?? null);
if (projectDir === s.activeProjectPath) {
// Files change throughout a turn, not only at its end: refresh as messages
// land, so the changes pane is live rather than stale for the whole
Expand All @@ -447,13 +456,13 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
if (event.type === "agent_settled") void get().refreshGit();
else if (event.type === "message_end" && !gitRefreshedWithin(1000)) void get().refreshGit();
}
patchConversation(set, projectDir, reduce(conv, event));
patchConversation(set, projectDir, sessionFile ?? null, { ...reduce(conv, event), projectDir, sessionFile: sessionFile ?? conv.sessionFile });
},

onPiError: (projectDir, message) => {
// The draft was cleared by the submit that succeeded, so there is nothing
// to re-send; restarting Pi is the recovery that actually applies.
patchConversation(set, projectDir, {
patchConversation(set, projectDir, get().activeProjectPath === projectDir ? get().activeSessionFile : null, {
error: message,
errorRecovery: "restartPi",
running: false,
Expand All @@ -462,9 +471,9 @@ export const createChatSlice: SliceCreator<ChatSlice> = (set, get) => ({
},

onSessionChangedExternally: ({ projectDir, sessionFile }) => {
const conv = get().conversations[projectDir];
const conv = get().conversations[sessionFile];
if (!conv || sessionFile !== conv.sessionFile) return;
if (conv.running || conv.externalChange) return;
patchConversation(set, projectDir, { externalChange: { sessionFile } });
patchConversation(set, projectDir, sessionFile, { externalChange: { sessionFile } });
},
});
11 changes: 7 additions & 4 deletions apps/desktop/src/renderer/lib/store/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AppState, Conversation, SetState } from "./types.ts";
*/
export function emptyConversation(): Conversation {
return {
projectDir: null,
sessionFile: null,
sessionName: undefined,
entries: [],
Expand All @@ -31,8 +32,8 @@ export function emptyConversation(): Conversation {
*/
const EMPTY_CONVERSATION: Conversation = Object.freeze(emptyConversation());

export function conversationFor(s: AppState, projectPath: string | null): Conversation {
return (projectPath ? s.conversations[projectPath] : undefined) ?? EMPTY_CONVERSATION;
export function conversationFor(s: AppState, projectPath: string | null, sessionFile: string | null = s.activeSessionFile): Conversation {
return (projectPath ? s.conversations[sessionFile ?? projectPath] : undefined) ?? EMPTY_CONVERSATION;
}

/** The conversation the UI is looking at. Components select through this. */
Expand All @@ -44,11 +45,13 @@ export function activeConversation(s: AppState): Conversation {
export function patchConversation(
set: SetState,
projectDir: string,
sessionFile: string | null,
patch: Partial<Conversation> | ((c: Conversation) => Partial<Conversation>),
): void {
set((s) => {
const current = s.conversations[projectDir] ?? emptyConversation();
const key = sessionFile ?? projectDir;
const current = s.conversations[key] ?? emptyConversation();
Comment thread
nonlooped marked this conversation as resolved.
const resolved = typeof patch === "function" ? patch(current) : patch;
return { conversations: { ...s.conversations, [projectDir]: { ...current, ...resolved } } };
return { conversations: { ...s.conversations, [key]: { ...current, ...resolved } } };
});
}
Loading
Loading