Skip to content

Commit 0bff0b7

Browse files
devartifexCopilot
andauthored
feat: SDK feature completion, UX improvements, security hardening
* ci: add CodeQL scanning workflow and secret scanning setup script - Create .github/workflows/codeql.yml (JS/TS analysis, weekly + PR triggers) - Create scripts/setup-security.sh for enabling secret scanning + push protection - Update SECURITY.md with secret scanning documentation Closes #78 Closes #79 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add branch protection script and release-please workflow - Create scripts/setup-branch-protection.sh (gh api, requires admin) - Create .github/workflows/release.yml (release-please for semver + changelog) - Create release-please-config.json and .release-please-manifest.json Closes #75 Closes #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: enhance CI with Playwright E2E, conventional commit check, caching - Add e2e job with Playwright desktop tests and artifact upload on failure - Add commit-lint job checking PR title against conventional commits pattern - Add concurrency group to cancel redundant runs - Add npm cache via setup-node Closes #70 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: enhance PR template, YAML issue forms, and CODEOWNERS - Upgrade PR template with GitHub Flow + security checklist - Convert issue templates from Markdown to YAML forms - Add SDK feature issue template - Add security advisory contact link - Create CODEOWNERS with path-based ownership Closes #73 Closes #74 Closes #77 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: add PR auto-labeler and stale issues/PR management - Create labeler config with 10 path-based labels (backend, frontend, sdk, etc.) - Create labeler.yml workflow using actions/labeler@v5 - Create stale.yml workflow (30-day stale, 7-day close, exempt security/killer-feature) Closes #71 Closes #72 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Copilot prompt files and rewrite copilot-instructions.md - Create 4 prompt files: generate-test, review-security, add-feature, fix-bug - Rewrite copilot-instructions.md with accurate counts (20 components, 78 message types) - Add skills system, testing sections, updated project structure Closes #76 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: adopt awesome-copilot skills, agents, instructions, and workflows Skills added (4): github-issues, doublecheck, copilot-spaces, automate-this Agents added (6): 4.1-Beast, critical-thinking, implementation-plan, refine-issue, polyglot-test-generator, adr-generator Instructions added (2): code-review-generic, performance-optimization Workflows added (2): codespell, check-pr-target Closes #86 Closes #87 Closes #88 Closes #69 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: expose infinite session compaction config in settings (#84) Add InfiniteSessionsSettings type with enabled, backgroundThreshold, and bufferThreshold fields. Wire through settings store (with clamping validation and localStorage persistence), WS types, WS store, page component, and handler mapping to SDK's InfiniteSessionConfig. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: SDK feature completion + UX improvements + security hardening SDK Features: - Image thumbnails in user messages with file chips (#31) - Directory and selection attachment types (#85) - Infinite session compaction config (#83) - @ file fuzzy mention with autocomplete (#34) - File serving route for uploaded images Security: - Path traversal protection on attachment validation - Workspace path validation for file mentions - Auth-required file serving endpoint Tests: 306 passing (29 test files) Closes #31 Closes #34 Closes #83 Closes #85 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fd75bed commit 0bff0b7

16 files changed

Lines changed: 1002 additions & 48 deletions

File tree

src/lib/components/ChatInput.svelte

Lines changed: 232 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
2-
import type { ConnectionState, FileAttachment, SessionMode, UserInputState } from '$lib/types/index.js';
2+
import { tick } from 'svelte';
3+
import type { Attachment, ConnectionState, FileAttachment, SessionMode, UserInputState } from '$lib/types/index.js';
34
45
interface Props {
56
connectionState: ConnectionState;
@@ -8,7 +9,7 @@
89
isWaiting: boolean;
910
mode: SessionMode;
1011
pendingUserInput: UserInputState | null;
11-
onSend: (content: string, attachments?: Array<{ path: string; name: string; type: string }>) => void;
12+
onSend: (content: string, attachments?: Attachment[]) => void;
1213
onAbort: () => void;
1314
onSetMode: (mode: SessionMode) => void;
1415
onUserInputResponse: (answer: string, wasFreeform: boolean) => void;
@@ -52,6 +53,16 @@
5253
let isUploading = $state(false);
5354
let attachMenuOpen = $state(false);
5455
56+
// @ file mention autocomplete state
57+
let mentionOpen = $state(false);
58+
let mentionQuery = $state('');
59+
let mentionStartPos = $state(0);
60+
let mentionFiles = $state<string[]>([]);
61+
let mentionIndex = $state(0);
62+
let mentionLoading = $state(false);
63+
let mentionListEl: HTMLUListElement | undefined = $state();
64+
let mentionFetchTimer: ReturnType<typeof setTimeout> | undefined;
65+
5566
const isDisabled = $derived(
5667
!pendingUserInput && (connectionState !== 'connected' || !sessionReady || isUploading),
5768
);
@@ -87,6 +98,9 @@
8798
}
8899
89100
function handleKeydown(event: KeyboardEvent) {
101+
// Handle @ mention keyboard navigation first
102+
if (handleMentionKeydown(event)) return;
103+
90104
if (event.key === 'Enter' && !event.shiftKey) {
91105
event.preventDefault();
92106
if (pendingUserInput) {
@@ -180,13 +194,13 @@
180194
const trimmed = inputValue.trim();
181195
if ((!trimmed && selectedFiles.length === 0) || isDisabled) return;
182196
183-
let attachments: Array<{ path: string; name: string; type: string }> | undefined;
197+
let attachments: Attachment[] | undefined;
184198
185199
if (selectedFiles.length > 0) {
186200
isUploading = true;
187201
try {
188202
const uploaded = await uploadFiles(selectedFiles);
189-
attachments = uploaded.map((f) => ({ path: f.path, name: f.name, type: f.type }));
203+
attachments = uploaded.map((f) => ({ type: 'file' as const, path: f.path, name: f.name }));
190204
} catch (err) {
191205
console.error('Upload failed:', err);
192206
isUploading = false;
@@ -209,6 +223,122 @@
209223
inputValue = inputValue.slice(0, MAX_LENGTH);
210224
}
211225
autoResize();
226+
detectMention();
227+
}
228+
229+
async function fetchMentionFiles(query: string) {
230+
mentionLoading = true;
231+
try {
232+
const params = query ? `?q=${encodeURIComponent(query)}` : '';
233+
const res = await fetch(`/api/files${params}`);
234+
if (!res.ok) {
235+
mentionFiles = [];
236+
return;
237+
}
238+
const data = await res.json();
239+
mentionFiles = Array.isArray(data.files) ? data.files : [];
240+
mentionIndex = 0;
241+
} catch {
242+
mentionFiles = [];
243+
} finally {
244+
mentionLoading = false;
245+
}
246+
}
247+
248+
function detectMention() {
249+
if (!textareaEl) return;
250+
const pos = textareaEl.selectionStart;
251+
const text = inputValue.slice(0, pos);
252+
253+
const lastAt = text.lastIndexOf('@');
254+
if (lastAt === -1) {
255+
closeMention();
256+
return;
257+
}
258+
259+
// @ must be at start of text or preceded by whitespace
260+
if (lastAt > 0 && !/\s/.test(text[lastAt - 1])) {
261+
closeMention();
262+
return;
263+
}
264+
265+
const query = text.slice(lastAt + 1);
266+
// If there's a space in the query, the mention is complete
267+
if (/\s/.test(query)) {
268+
closeMention();
269+
return;
270+
}
271+
272+
mentionStartPos = lastAt;
273+
mentionQuery = query;
274+
mentionOpen = true;
275+
276+
if (mentionFetchTimer) clearTimeout(mentionFetchTimer);
277+
mentionFetchTimer = setTimeout(() => fetchMentionFiles(query), 150);
278+
}
279+
280+
function closeMention() {
281+
mentionOpen = false;
282+
mentionFiles = [];
283+
mentionQuery = '';
284+
mentionIndex = 0;
285+
if (mentionFetchTimer) {
286+
clearTimeout(mentionFetchTimer);
287+
mentionFetchTimer = undefined;
288+
}
289+
}
290+
291+
function selectMentionFile(filePath: string) {
292+
if (!textareaEl) return;
293+
const before = inputValue.slice(0, mentionStartPos);
294+
const after = inputValue.slice(textareaEl.selectionStart);
295+
inputValue = `${before}@${filePath}${after ? '' : ' '}${after}`;
296+
closeMention();
297+
tick().then(() => {
298+
if (textareaEl) {
299+
const newPos = before.length + 1 + filePath.length + (after ? 0 : 1);
300+
textareaEl.selectionStart = newPos;
301+
textareaEl.selectionEnd = newPos;
302+
textareaEl.focus();
303+
autoResize();
304+
}
305+
});
306+
}
307+
308+
function handleMentionKeydown(event: KeyboardEvent): boolean {
309+
if (!mentionOpen || mentionFiles.length === 0) return false;
310+
311+
switch (event.key) {
312+
case 'ArrowDown':
313+
event.preventDefault();
314+
mentionIndex = (mentionIndex + 1) % mentionFiles.length;
315+
scrollMentionIntoView();
316+
return true;
317+
case 'ArrowUp':
318+
event.preventDefault();
319+
mentionIndex = (mentionIndex - 1 + mentionFiles.length) % mentionFiles.length;
320+
scrollMentionIntoView();
321+
return true;
322+
case 'Enter':
323+
case 'Tab':
324+
event.preventDefault();
325+
selectMentionFile(mentionFiles[mentionIndex]);
326+
return true;
327+
case 'Escape':
328+
event.preventDefault();
329+
closeMention();
330+
return true;
331+
default:
332+
return false;
333+
}
334+
}
335+
336+
function scrollMentionIntoView() {
337+
tick().then(() => {
338+
if (!mentionListEl) return;
339+
const active = mentionListEl.querySelector('[aria-selected="true"]');
340+
active?.scrollIntoView({ block: 'nearest' });
341+
});
212342
}
213343
214344
function formatFileSize(bytes: number): string {
@@ -317,6 +447,33 @@
317447
</div>
318448
{/if}
319449

450+
{#if mentionOpen && (mentionFiles.length > 0 || mentionLoading)}
451+
<div class="mention-popover" role="listbox" aria-label="File mentions">
452+
{#if mentionLoading && mentionFiles.length === 0}
453+
<div class="mention-loading">Searching files…</div>
454+
{:else}
455+
<ul class="mention-list" bind:this={mentionListEl}>
456+
{#each mentionFiles.slice(0, 8) as file, i (file)}
457+
<li
458+
class="mention-item"
459+
class:active={i === mentionIndex}
460+
role="option"
461+
aria-selected={i === mentionIndex}
462+
onmousedown={(e) => { e.preventDefault(); selectMentionFile(file); }}
463+
onmouseenter={() => { mentionIndex = i; }}
464+
>
465+
<span class="mention-icon" aria-hidden="true">📄</span>
466+
<span class="mention-path">{file}</span>
467+
</li>
468+
{/each}
469+
</ul>
470+
{#if mentionFiles.length > 8}
471+
<div class="mention-more">{mentionFiles.length - 8} more…</div>
472+
{/if}
473+
{/if}
474+
</div>
475+
{/if}
476+
320477
{#if showSteeringIndicator}
321478
<div class="steering-indicator" role="status" aria-live="polite">
322479
Sending now will steer the current response.
@@ -614,6 +771,77 @@
614771
color: var(--fg-muted);
615772
}
616773
774+
/* ── @ File mention popover ────────────────────────────────────── */
775+
.mention-popover {
776+
position: absolute;
777+
bottom: 100%;
778+
left: var(--sp-2);
779+
right: var(--sp-2);
780+
background: var(--bg-raised, var(--bg-overlay));
781+
border: 1px solid var(--border);
782+
border-radius: var(--radius);
783+
margin-bottom: var(--sp-1);
784+
z-index: 12;
785+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
786+
animation: userInputIn 0.12s ease;
787+
overflow: hidden;
788+
}
789+
790+
.mention-loading {
791+
padding: var(--sp-2) var(--sp-3);
792+
color: var(--fg-dim);
793+
font-family: var(--font-mono);
794+
font-size: 0.82em;
795+
}
796+
797+
.mention-list {
798+
list-style: none;
799+
margin: 0;
800+
padding: var(--sp-1) 0;
801+
max-height: 280px;
802+
overflow-y: auto;
803+
scrollbar-width: thin;
804+
}
805+
806+
.mention-item {
807+
display: flex;
808+
align-items: center;
809+
gap: var(--sp-2);
810+
padding: var(--sp-1) var(--sp-3);
811+
cursor: pointer;
812+
font-family: var(--font-mono);
813+
font-size: 0.82em;
814+
color: var(--fg);
815+
transition: background 0.08s ease;
816+
min-height: 32px;
817+
}
818+
819+
.mention-item:hover,
820+
.mention-item.active {
821+
background: var(--bg-secondary, rgba(255, 255, 255, 0.08));
822+
}
823+
824+
.mention-icon {
825+
flex-shrink: 0;
826+
font-size: 0.9em;
827+
}
828+
829+
.mention-path {
830+
overflow: hidden;
831+
text-overflow: ellipsis;
832+
white-space: nowrap;
833+
min-width: 0;
834+
}
835+
836+
.mention-more {
837+
padding: var(--sp-1) var(--sp-3);
838+
color: var(--fg-dim);
839+
font-family: var(--font-mono);
840+
font-size: 0.75em;
841+
border-top: 1px solid var(--border);
842+
text-align: center;
843+
}
844+
617845
.fleet-btn {
618846
color: var(--purple) !important;
619847
}

0 commit comments

Comments
 (0)