mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Keep issue chat composer visible while typing
This commit is contained in:
parent
59d913d04b
commit
15b0f11275
2 changed files with 127 additions and 81 deletions
|
|
@ -52,13 +52,19 @@ vi.mock("./MarkdownEditor", () => ({
|
||||||
value = "",
|
value = "",
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Issue chat editor"
|
aria-label="Issue chat editor"
|
||||||
|
data-class-name={className}
|
||||||
|
data-content-class-name={contentClassName}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
|
@ -240,6 +246,39 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the composer pinned with a capped editor height", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
expect(composer?.className).toContain("sticky");
|
||||||
|
expect(composer?.className).toContain("bottom-0");
|
||||||
|
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+0.75rem)]");
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
expect(editor?.dataset.contentClassName).toContain("max-h-[40dvh]");
|
||||||
|
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||||
expect(resolveAssistantMessageFoldedState({
|
expect(resolveAssistantMessageFoldedState({
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
|
|
|
||||||
|
|
@ -1488,92 +1488,99 @@ function IssueChatComposer({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
<MarkdownEditor
|
data-testid="issue-chat-composer"
|
||||||
ref={editorRef}
|
className="sticky bottom-0 z-10 bg-gradient-to-t from-background via-background/95 to-background/70 pt-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur supports-[backdrop-filter]:from-background/92 supports-[backdrop-filter]:via-background/82"
|
||||||
value={body}
|
>
|
||||||
onChange={setBody}
|
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/95 p-3 shadow-sm">
|
||||||
placeholder="Reply"
|
<MarkdownEditor
|
||||||
mentions={mentions}
|
ref={editorRef}
|
||||||
onSubmit={handleSubmit}
|
value={body}
|
||||||
imageUploadHandler={onImageUpload}
|
onChange={setBody}
|
||||||
contentClassName="min-h-[72px] text-sm"
|
placeholder="Reply"
|
||||||
/>
|
mentions={mentions}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
imageUploadHandler={onImageUpload}
|
||||||
|
bordered
|
||||||
|
className="rounded-xl border-border/70 bg-background/95"
|
||||||
|
contentClassName="min-h-[72px] max-h-[40dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-border/60 pt-2">
|
||||||
{(onImageUpload || onAttachImage) ? (
|
{(onImageUpload || onAttachImage) ? (
|
||||||
<div className="mr-auto flex items-center gap-3">
|
<div className="mr-auto flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={attachInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAttachFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => attachInputRef.current?.click()}
|
||||||
|
disabled={attaching}
|
||||||
|
title="Attach image"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
ref={attachInputRef}
|
type="checkbox"
|
||||||
type="file"
|
checked={reopen}
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
onChange={(event) => setReopen(event.target.checked)}
|
||||||
className="hidden"
|
className="rounded border-border"
|
||||||
onChange={handleAttachFile}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
Re-open
|
||||||
variant="ghost"
|
</label>
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => attachInputRef.current?.click()}
|
|
||||||
disabled={attaching}
|
|
||||||
title="Attach image"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
{enableReassign && reassignOptions.length > 0 ? (
|
||||||
<input
|
<InlineEntitySelector
|
||||||
type="checkbox"
|
value={reassignTarget}
|
||||||
checked={reopen}
|
options={reassignOptions}
|
||||||
onChange={(event) => setReopen(event.target.checked)}
|
placeholder="Assignee"
|
||||||
className="rounded border-border"
|
noneLabel="No assignee"
|
||||||
/>
|
searchPlaceholder="Search assignees..."
|
||||||
Re-open
|
emptyMessage="No assignees found."
|
||||||
</label>
|
onChange={setReassignTarget}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
renderTriggerValue={(option) => {
|
||||||
|
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||||
|
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||||
|
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{agent ? (
|
||||||
|
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||||
|
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{agent ? (
|
||||||
|
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{enableReassign && reassignOptions.length > 0 ? (
|
<Button size="sm" disabled={!canSubmit} onClick={() => void handleSubmit()}>
|
||||||
<InlineEntitySelector
|
{submitting ? "Posting..." : "Send"}
|
||||||
value={reassignTarget}
|
</Button>
|
||||||
options={reassignOptions}
|
</div>
|
||||||
placeholder="Assignee"
|
|
||||||
noneLabel="No assignee"
|
|
||||||
searchPlaceholder="Search assignees..."
|
|
||||||
emptyMessage="No assignees found."
|
|
||||||
onChange={setReassignTarget}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
renderTriggerValue={(option) => {
|
|
||||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
|
||||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
|
||||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{agent ? (
|
|
||||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{option.label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderOption={(option) => {
|
|
||||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
||||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
|
||||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{agent ? (
|
|
||||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{option.label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button size="sm" disabled={!canSubmit} onClick={() => void handleSubmit()}>
|
|
||||||
{submitting ? "Posting..." : "Send"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue