Compare commits

...

7 Commits

Author SHA1 Message Date
934f739ca1 Merge branch 'v1.7-drag-drop' 2026-05-16 15:35:07 +00:00
e9895fd694 Merge branch 'v1.6.3-mobile-root-nav' 2026-05-16 15:34:56 +00:00
83c7d33f3c Merge branch 'v1.6.5-session-rename-publish' 2026-05-16 15:34:47 +00:00
c3415574d6 Merge branch 'v1.6.4-auto-name-sessions' 2026-05-16 15:34:36 +00:00
50a756aca1 feat(input): drag-drop + paste-as-attachment for long text 2026-05-16 15:23:41 +00:00
5ee266a4d9 feat(auto_name): propagate first chat name to parent session
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.

Closes the gap where sessions auto-created from the sidebar would
stay 'New session' forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:23:11 +00:00
c750ce9e62 fix(api): suppress no-op session_renamed publish on PATCH /api/sessions/:id
The v1.4 publisher fired whenever the PATCH body included `name`,
including no-op rename calls (PATCH { name } where name ===
currentName). Read the prior name with a fast SELECT before the
UPDATE and only publish session_renamed when the post-update name
actually differs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:20:03 +00:00
5 changed files with 259 additions and 6 deletions

View File

@@ -120,6 +120,15 @@ export function registerSessionRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model, system_prompt } = parsed.data;
// Read the prior name so the post-update publish can skip no-op renames
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler;
// a concurrent rename in that gap would just mean one stale publish, which
// existing clients dedup by id.
const before = await sql<{ name: string }[]>`
SELECT name FROM sessions WHERE id = ${req.params.id}
`;
const priorName = before[0]?.name;
const rows = await sql<Session[]>`
UPDATE sessions
SET
@@ -135,7 +144,7 @@ export function registerSessionRoutes(
return { error: 'session not found' };
}
const session = rows[0]!;
if (name !== undefined) {
if (name !== undefined && session.name !== priorName) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,

View File

@@ -144,4 +144,23 @@ export async function maybeAutoNameChat(
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
// Propagate to the parent session if it's still on its default name.
// The WHERE guard makes the check atomic — if the user has already
// renamed (or a prior chat already propagated), this UPDATE matches
// zero rows and we do nothing. First chat wins; manual renames win.
const renamedSession = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}
WHERE id = ${sessionId} AND name = 'New session'
RETURNING id, name
`;
if (renamedSession.length > 0) {
ctx.publishUser({
type: 'session_renamed',
session_id: sessionId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named from chat');
}
}

View File

@@ -1,16 +1,26 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import {
flattenToMessage,
inferLanguage,
looksBinary,
MAX_FILE_SIZE_BYTES,
PASTE_INLINE_MAX_LINES,
type Attachment,
} from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
interface Props {
disabled?: boolean;
projectId: string;
@@ -24,6 +34,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dropRootRef = useRef<HTMLDivElement | null>(null);
const pasteCounterRef = useRef(0);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
@@ -35,8 +48,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
if (prev.length >= MAX_ATTACHMENTS) {
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
return prev;
}
return [...prev, a];
@@ -185,6 +198,162 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const closeMention = useCallback(() => setMentionState(null), []);
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
// onDragLeave only clears the highlight when the cursor leaves the
// container, not when it crosses into a child element.
async function processDroppedFile(file: File) {
// Size gate
if (file.size > MAX_FILE_SIZE_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
return;
}
// Read once as ArrayBuffer so we can do byte-level binary detection
// before deciding whether to decode as text.
let buf: ArrayBuffer;
try {
buf = await file.arrayBuffer();
} catch (err) {
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
return;
}
if (looksBinary(buf)) {
toast.error(`${file.name} appears to be binary.`);
return;
}
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: file.name,
language: inferLanguage(file.name),
content: text,
source: 'drop',
});
}
function isFolderItem(item: DataTransferItem | undefined): boolean {
if (!item) return false;
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
// If unavailable, we conservatively treat the entry as a file.
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry && entry.isDirectory) return true;
// Heuristic fallback: folders dragged from Finder have type === '' and
// a 0-byte File. The empty-type alone isn't reliable for files (some
// plaintext drops also lack a type), so we only flag when the entry
// explicitly says directory.
return false;
}
async function handleDroppedItems(dt: DataTransfer) {
// Snapshot items first because reading files inside the loop can
// detach the DataTransfer between awaits.
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
const it = dt.items[i];
if (!it || it.kind !== 'file') continue;
const folder = isFolderItem(it);
const file = folder ? null : it.getAsFile();
itemsArray.push({ file, isFolder: folder });
}
} else {
for (let i = 0; i < dt.files.length; i++) {
const f = dt.files[i];
if (f) itemsArray.push({ file: f, isFolder: false });
}
}
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
let folderRejected = false;
for (const { file, isFolder } of itemsArray) {
if (isFolder) {
if (!folderRejected) {
toast.error('Folders are not supported');
folderRejected = true;
}
continue;
}
if (!file) continue;
if (remainingSlots <= 0) {
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
return;
}
await processDroppedFile(file);
remainingSlots -= 1;
}
}
function onDragEnter(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
setIsDraggingOver(true);
}
function onDragOver(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(e: DragEvent<HTMLDivElement>) {
// Only clear when the cursor actually leaves the root container.
// relatedTarget is the element being entered; if it's inside the root,
// ignore — we're just crossing into a child.
const root = dropRootRef.current;
if (!root) return;
const related = e.relatedTarget as Node | null;
if (related && root.contains(related)) return;
setIsDraggingOver(false);
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDraggingOver(false);
if (disabled || busy) return;
void handleDroppedItems(e.dataTransfer);
}
// ---- end Drag & drop -----------------------------------------------------
// ---- Paste-as-attachment (F2) -------------------------------------------
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
// inline content. Image pastes are rejected with a toast. If both text and
// image are present (e.g. screenshot tool that sets both), prefer text.
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const cd = e.clipboardData;
if (!cd) return;
const text = cd.getData('text/plain');
const hasImage = Array.from(cd.items ?? []).some((it) =>
it.type.startsWith('image/'),
);
if (text) {
const lineCount = text.split('\n').length;
if (lineCount > PASTE_INLINE_MAX_LINES) {
e.preventDefault();
pasteCounterRef.current += 1;
addAttachment({
id: crypto.randomUUID(),
kind: 'paste',
filename: `pasted-${pasteCounterRef.current}.txt`,
language: 'plaintext',
content: text,
source: 'paste',
});
}
// <= threshold: let default paste insert inline.
return;
}
if (hasImage) {
e.preventDefault();
toast.error('Image paste is not supported. Drop a file or paste text.');
}
}
// ---- end Paste-as-attachment --------------------------------------------
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
// IME safety: never act on Enter while an IME composition is in flight
@@ -228,7 +397,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
return (
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div
ref={dropRootRef}
className="border-t relative"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<DropOverlay visible={isDraggingOver} />
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
@@ -248,6 +426,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={
isMobile
? 'Ask about this project. Tap send to submit.'

View File

@@ -0,0 +1,18 @@
interface Props {
visible: boolean;
}
// Visual cue layered over the ChatInput while a drag is in progress.
// Pointer-events: none so the underlying drop handler still receives the
// drop event. Renders nothing when not visible (cheap and out of layout).
export function DropOverlay({ visible }: Props) {
if (!visible) return null;
return (
<div
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center rounded border-2 border-dashed border-primary bg-background/85"
aria-hidden="true"
>
<div className="text-sm font-medium text-primary">Drop to attach</div>
</div>
);
}

View File

@@ -8,6 +8,34 @@ export type Attachment = {
source: '@' | 'line-select' | 'drop' | 'paste';
};
// v1.7: caps shared between drag-drop and paste-as-attachment so both paths
// reject the same way. Match the existing 10-attachment cap in
// ChatInput.addAttachment.
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const PASTE_INLINE_MAX_LINES = 8;
// First-8KB null-byte scan. Returns true if the content looks binary.
// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array.
// For binary files like PNG, scanning bytes is more reliable than scanning
// post-UTF-8-decode strings because invalid sequences may be replaced rather
// than preserved.
export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean {
const SCAN_BYTES = 8192;
if (typeof content === 'string') {
const max = Math.min(content.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (content.charCodeAt(i) === 0) return true;
}
return false;
}
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
const max = Math.min(bytes.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (bytes[i] === 0) return true;
}
return false;
}
export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',