Skip to content

Commit d4f7a39

Browse files
authored
Add prompt stashing across threads (#93)
- Persist stashed prompts with attachments and composer context - Restore stashes through the composer control and mod+s shortcut - Add image compression, stash persistence tests, and keybinding updates
1 parent eb1c4a5 commit d4f7a39

12 files changed

Lines changed: 2184 additions & 160 deletions

KEYBINDINGS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
2727
{ "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
2828
{ "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
2929
{ "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
30+
{ "key": "mod+s", "command": "composer.stash", "when": "!terminalFocus" },
3031
{ "key": "mod+o", "command": "editor.openFavorite" }
3132
]
3233
```
@@ -54,6 +55,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
5455
- `commandPalette.toggle`: open or close the global command palette
5556
- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5657
- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
58+
- `composer.stash`: stash the composer's prompt for later; with an empty composer, open the stash list
5759
- `editor.openFavorite`: open current project/worktree in the last-used editor
5860
- `script.{id}.run`: run a project script by id (for example `script.test.run`)
5961

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import "../../index.css";
2+
3+
import { ThreadId } from "@threadlines/contracts";
4+
import { useState } from "react";
5+
import { page } from "vite-plus/test/browser";
6+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
7+
import { render } from "vitest-browser-react";
8+
9+
import type { PromptStashEntry } from "../../promptStashStore";
10+
import { ComposerStashControl } from "./ComposerStashControl";
11+
12+
function makeEntry(input: { id: string; prompt: string; withChip?: boolean }): PromptStashEntry {
13+
return {
14+
id: input.id,
15+
createdAt: new Date().toISOString(),
16+
prompt: input.prompt,
17+
attachments: [],
18+
droppedAttachmentNames: [],
19+
...(input.withChip
20+
? {
21+
fileSelectionContexts: [
22+
{
23+
id: `${input.id}-file`,
24+
threadId: ThreadId.make("thread-1"),
25+
createdAt: new Date().toISOString(),
26+
relativePath: "src/app.ts",
27+
startLine: 1,
28+
endLine: 4,
29+
selectedText: "const a = 1;",
30+
},
31+
],
32+
}
33+
: {}),
34+
};
35+
}
36+
37+
function renderStashControl(props: {
38+
entries: ReadonlyArray<PromptStashEntry>;
39+
canStash: boolean;
40+
onRestore?: (entry: PromptStashEntry) => void;
41+
onDelete?: (entry: PromptStashEntry) => void;
42+
}) {
43+
function Harness() {
44+
const [open, setOpen] = useState(false);
45+
return (
46+
<ComposerStashControl
47+
entries={props.entries}
48+
open={open}
49+
onOpenChange={setOpen}
50+
canStash={props.canStash}
51+
stashShortcutLabel="⌘S"
52+
onStash={vi.fn()}
53+
onRestore={props.onRestore ?? vi.fn()}
54+
onDelete={props.onDelete ?? vi.fn()}
55+
/>
56+
);
57+
}
58+
return render(<Harness />);
59+
}
60+
61+
describe("ComposerStashControl", () => {
62+
afterEach(() => {
63+
document.body.innerHTML = "";
64+
});
65+
66+
it("lists stashed prompts with their chip count and restores the row that was clicked", async () => {
67+
const onRestore = vi.fn();
68+
const screen = await renderStashControl({
69+
entries: [
70+
makeEntry({ id: "newest", prompt: "rewrite the parser", withChip: true }),
71+
makeEntry({ id: "older", prompt: "add a retry" }),
72+
],
73+
canStash: true,
74+
onRestore,
75+
});
76+
77+
await page.getByRole("button", { name: "Stashed prompts (2)" }).click();
78+
await expect.element(page.getByRole("menuitem", { name: /Stash this prompt/ })).toBeVisible();
79+
await expect.element(page.getByText("1 chip")).toBeVisible();
80+
81+
await page.getByRole("menuitem", { name: /rewrite the parser/ }).click();
82+
expect(onRestore).toHaveBeenCalledTimes(1);
83+
expect(onRestore.mock.calls[0]?.[0]?.id).toBe("newest");
84+
await screen.unmount();
85+
});
86+
87+
it("deletes an entry without restoring it, and offers no stash action on an empty composer", async () => {
88+
const onRestore = vi.fn();
89+
const onDelete = vi.fn();
90+
const screen = await renderStashControl({
91+
entries: [makeEntry({ id: "only", prompt: "ship the fix" })],
92+
canStash: false,
93+
onRestore,
94+
onDelete,
95+
});
96+
97+
await page.getByRole("button", { name: "Stashed prompts (1)" }).click();
98+
// Nothing typed, so the popover is a list only.
99+
expect(page.getByRole("menuitem", { name: /Stash this prompt/ }).query()).toBeNull();
100+
101+
await page.getByRole("button", { name: /Delete stashed prompt/ }).click();
102+
expect(onDelete).toHaveBeenCalledTimes(1);
103+
// The delete must not double as an activation of the row it sits in.
104+
expect(onRestore).not.toHaveBeenCalled();
105+
await screen.unmount();
106+
});
107+
108+
it("explains how to fill an empty stash", async () => {
109+
const screen = await renderStashControl({ entries: [], canStash: false });
110+
111+
await page.getByRole("button", { name: "Stashed prompts" }).click();
112+
await expect.element(page.getByText(/Nothing stashed yet/)).toBeVisible();
113+
await screen.unmount();
114+
});
115+
});
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { BookmarkIcon, XIcon } from "lucide-react";
2+
import { memo } from "react";
3+
4+
import { cn } from "~/lib/utils";
5+
import { stripInlineTerminalContextPlaceholders } from "../../lib/terminalContext";
6+
import { stashEntryChipCount, type PromptStashEntry } from "../../promptStashStore";
7+
import { formatRelativeTimeLabel } from "../../timestampFormat";
8+
import { Button } from "../ui/button";
9+
import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu";
10+
11+
const SNIPPET_MAX_CHARS = 90;
12+
const MAX_ROW_THUMBNAILS = 3;
13+
14+
export interface ComposerStashControlProps {
15+
entries: ReadonlyArray<PromptStashEntry>;
16+
open: boolean;
17+
onOpenChange: (open: boolean) => void;
18+
/** The composer has something worth stashing right now. */
19+
canStash: boolean;
20+
/** Rendered right-aligned on the stash action row; null when unbound. */
21+
stashShortcutLabel: string | null;
22+
onStash: () => void;
23+
onRestore: (entry: PromptStashEntry) => void;
24+
onDelete: (entry: PromptStashEntry) => void;
25+
}
26+
27+
/** Attachments that did not make it into the entry, whatever the reason. */
28+
function missingAttachmentCount(entry: PromptStashEntry): number {
29+
return entry.droppedAttachmentNames.length + (entry.unreadableAttachmentNames?.length ?? 0);
30+
}
31+
32+
function stashEntrySnippet(entry: PromptStashEntry): string {
33+
// Inline terminal placeholders are invisible object-replacement characters
34+
// in the live composer; left in they would render as tofu in the list.
35+
const trimmed = stripInlineTerminalContextPlaceholders(entry.prompt).trim().replace(/\s+/g, " ");
36+
if (trimmed.length > 0) {
37+
return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed;
38+
}
39+
const attachmentCount = entry.attachments.length + entry.droppedAttachmentNames.length;
40+
if (attachmentCount > 0) {
41+
return `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;
42+
}
43+
const chipCount = stashEntryChipCount(entry);
44+
return chipCount > 0 ? `${chipCount} chip${chipCount === 1 ? "" : "s"}` : "Empty prompt";
45+
}
46+
47+
const StashEntryRow = memo(function StashEntryRow(props: {
48+
entry: PromptStashEntry;
49+
onRestore: (entry: PromptStashEntry) => void;
50+
onDelete: (entry: PromptStashEntry) => void;
51+
}) {
52+
const { entry, onRestore, onDelete } = props;
53+
const thumbnails = entry.attachments
54+
.filter((attachment) => attachment.mimeType.startsWith("image/"))
55+
.slice(0, MAX_ROW_THUMBNAILS);
56+
const chipCount = stashEntryChipCount(entry);
57+
const missingCount = missingAttachmentCount(entry);
58+
59+
return (
60+
<MenuItem
61+
className="group/stash gap-2 rounded-none border-border border-b px-2 last:border-b-0"
62+
onClick={() => {
63+
onRestore(entry);
64+
}}
65+
>
66+
{thumbnails.length > 0 ? (
67+
<span className="-space-x-1.5 flex shrink-0 items-center">
68+
{thumbnails.map((attachment) => (
69+
<img
70+
key={attachment.id}
71+
src={attachment.dataUrl}
72+
alt=""
73+
aria-hidden="true"
74+
className="size-5 rounded-xs border border-border object-cover"
75+
/>
76+
))}
77+
</span>
78+
) : (
79+
<BookmarkIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
80+
)}
81+
<span className="min-w-0 flex-1 truncate">{stashEntrySnippet(entry)}</span>
82+
{chipCount > 0 ? (
83+
<span className="shrink-0 text-muted-foreground text-xs">
84+
{chipCount} chip{chipCount === 1 ? "" : "s"}
85+
</span>
86+
) : null}
87+
{entry.pendingAttachmentCount ? (
88+
<span className="shrink-0 text-muted-foreground text-xs">
89+
saving {entry.pendingAttachmentCount}
90+
</span>
91+
) : missingCount > 0 ? (
92+
<span className="shrink-0 text-warning-foreground text-xs">{missingCount} dropped</span>
93+
) : null}
94+
<span className="shrink-0 font-mono text-muted-foreground text-xs tabular-nums">
95+
{formatRelativeTimeLabel(entry.createdAt)}
96+
</span>
97+
<Button
98+
type="button"
99+
variant="ghost"
100+
size="icon-xs"
101+
className="shrink-0 text-muted-foreground opacity-0 group-hover/stash:opacity-100"
102+
aria-label={`Delete stashed prompt: ${stashEntrySnippet(entry)}`}
103+
// The row itself restores on click; without both of these the delete
104+
// would restore the very prompt it is meant to throw away.
105+
onPointerDown={(event) => {
106+
event.preventDefault();
107+
event.stopPropagation();
108+
}}
109+
onClick={(event) => {
110+
event.preventDefault();
111+
event.stopPropagation();
112+
onDelete(entry);
113+
}}
114+
>
115+
<XIcon aria-hidden="true" />
116+
</Button>
117+
</MenuItem>
118+
);
119+
});
120+
121+
/**
122+
* Popover body: the stash action for the current prompt, then the queue
123+
* itself as a dense divided list.
124+
*/
125+
const ComposerStashList = memo(function ComposerStashList(props: ComposerStashControlProps) {
126+
const { entries, canStash, stashShortcutLabel, onStash, onRestore, onDelete } = props;
127+
128+
return (
129+
<>
130+
{canStash ? (
131+
<>
132+
<MenuItem onClick={onStash}>
133+
<BookmarkIcon aria-hidden="true" />
134+
Stash this prompt
135+
{stashShortcutLabel ? <MenuShortcut>{stashShortcutLabel}</MenuShortcut> : null}
136+
</MenuItem>
137+
<MenuSeparator />
138+
</>
139+
) : null}
140+
{entries.length === 0 ? (
141+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
142+
Nothing stashed yet. Press {stashShortcutLabel ?? "the stash shortcut"} with a prompt
143+
typed to stash it.
144+
</p>
145+
) : (
146+
entries.map((entry) => (
147+
<StashEntryRow key={entry.id} entry={entry} onRestore={onRestore} onDelete={onDelete} />
148+
))
149+
)}
150+
</>
151+
);
152+
});
153+
154+
/**
155+
* Composer footer stash control: a bookmark button carrying the queue depth,
156+
* opening the stashed prompts above it. Always visible, so the queue is never
157+
* something the user has to remember a shortcut to find.
158+
*/
159+
export const ComposerStashControl = memo(function ComposerStashControl(
160+
props: ComposerStashControlProps,
161+
) {
162+
const { entries, open, onOpenChange } = props;
163+
164+
return (
165+
<Menu open={open} onOpenChange={onOpenChange}>
166+
<MenuTrigger
167+
render={
168+
<Button
169+
type="button"
170+
variant="ghost"
171+
size={entries.length > 0 ? "sm" : "icon-sm"}
172+
className={cn(
173+
"rounded-full text-muted-foreground/70 hover:text-foreground/80",
174+
entries.length > 0 && "gap-1 px-2",
175+
)}
176+
aria-label={
177+
entries.length > 0 ? `Stashed prompts (${entries.length})` : "Stashed prompts"
178+
}
179+
/>
180+
}
181+
>
182+
<BookmarkIcon className="size-4" aria-hidden="true" />
183+
{entries.length > 0 ? <span className="text-xs tabular-nums">{entries.length}</span> : null}
184+
</MenuTrigger>
185+
<MenuPopup align="end" side="top" className="w-96 max-w-[min(24rem,calc(100vw-2rem))]">
186+
<ComposerStashList {...props} />
187+
</MenuPopup>
188+
</Menu>
189+
);
190+
});

0 commit comments

Comments
 (0)