Trin 2. Serveren kunne allerede lade være med at montere opgaveruterne; fladen tilbød dem stadig, så en noter-instans havde links til sider, der ikke fandtes. --- hvordan fladen får det at vide --- `/auth/setup` bærer nu `edition`. Dér og ikke på `/auth/me`: udgaven er en egenskab ved instansen, ikke ved den, der er logget ind, og fladen skal kende den, før nogen er. Det er i forvejen kaldet, der spørger "hvad er det her for en instans", så den følger med uden en ekstra tur. Butikken henter formen ved siden af kontoen og tier, hvis den fejler: en 401 fra `me` må ikke tage udgaven med sig — login-skærmen er også en flade i en udgave. Afventet før `loading` slippes, så fladen aldrig tegnes, før den ved hvilket program den er; tegnet først og rettet bagefter ville vise opgaver i et sekund på en instans, der ikke har nogen. `full` som udgangspunkt, fordi det er det, programmet altid har været. --- hvad der forsvinder --- Menuens opgaveindgange er udeladt, ikke gråtonet. Forsiden er noterne. En gemt adresse til /upcoming, /kalender, /uddelegeret, /faerdige, /opgave, /filter eller /etiket fører til noterne frem for en blindgyde — der er ikke sket noget forkert, adressen hører bare til et andet program end det, der kører her. Listen står ét sted i layoutet frem for som en vagt pr. rute: listen ER beslutningen, og en vagt pr. fil er syv steder at glemme den ottende. --- et hul, prøven fandt --- /search er monteret i begge udgaver, fordi noter og notesbøger skal kunne findes — og den returnerede opgaver. Ikke teoretisk: en instans lavet om fra en fuld har opgaverne liggende i databasen endnu, så ⌘K ville være den ene dør, der stod åben ind til den halvdel, resten af programmet lige havde lukket. Lukket på serveren, hvor det hører hjemme, og ⌘K spørger også selv — en tom liste af den rigtige grund og en tom liste ved et tilfælde ser ens ud. Prøven har en kontrol: den fulde udgave SKAL finde opgaven, ellers beviser nullet i noter-udgaven ingenting. --- prøverne --- Et nyt Playwright-projekt med sin egen server, sin egen port og sin egen database. Ingen lånt session: den instans har aldrig set den konto, den fulde udgaves opsætning lavede. En skakt, der kun er prøvet ved at kalde en funktion, er prøvet på det, nogen mente — det her måler programmet, som en bruger møder det. Seks prøver, heriblandt forskellen på skjult og fraværende målt udefra: /api/v1/tasks, /labels, /upcoming og /filters skal svare 404, og /api/v1/notes skal svare 200 — ellers ville de fire bestå på en server, der bare var væk. `testIgnore` på chromium-projektet, fordi filen ellers også kørte dér, mod den fulde udgave, hvor den fejler med beskeder der læser som om skakten ikke virker. Den rigtige fejl stod i projektnavnet, ikke i påstanden. Go-suiten grøn. chromium + notes-edition: 110 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
9.6 KiB
Svelte
383 lines
9.6 KiB
Svelte
<script>
|
|
/** Cmd+K: search across everything the user can see. */
|
|
import { api } from '$lib/api.js';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
import { goto } from '$app/navigation';
|
|
import { app } from '$lib/stores.svelte.js';
|
|
import { humanMessage } from '$lib/api.js';
|
|
import { findSettings } from '$lib/settingsindex.js';
|
|
|
|
let { open = $bindable(false) } = $props();
|
|
|
|
let query = $state('');
|
|
let tasks = $state([]);
|
|
let projects = $state([]);
|
|
let notes = $state([]);
|
|
let selected = $state(0);
|
|
// Svaret på et spørgsmål, når nogen har stillet et. Søgningen finder det, der
|
|
// indeholder ordene; det her svarer på spørgsmålet — og de to bor i det samme
|
|
// felt, fordi man ikke skal vide på forhånd, hvilken slags spørgsmål man har.
|
|
let answer = $state(null);
|
|
let asking = $state(false);
|
|
let input;
|
|
let controller = null;
|
|
let timer = null;
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
query = '';
|
|
tasks = [];
|
|
projects = [];
|
|
answer = null;
|
|
selected = 0;
|
|
queueMicrotask(() => input?.focus());
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const value = query.trim();
|
|
clearTimeout(timer);
|
|
controller?.abort();
|
|
|
|
if (!value) {
|
|
tasks = [];
|
|
projects = [];
|
|
notes = [];
|
|
answer = null;
|
|
return;
|
|
}
|
|
// Et nyt bogstav gør det forrige svar forældet. Et svar, der bliver
|
|
// stående, mens spørgsmålet ændrer sig, er et svar på noget andet.
|
|
answer = null;
|
|
timer = setTimeout(async () => {
|
|
try {
|
|
// Noter er sit eget endepunkt, så de hentes ved siden af. Uden dem
|
|
// søger ⌘K kun i halvdelen af programmet, hvilket er svært at gætte
|
|
// som bruger: feltet ser ud til at kunne finde alt.
|
|
const [result, found] = await Promise.all([
|
|
api.search(value),
|
|
api.notes({ q: value }).catch(() => ({ notes: [] }))
|
|
]);
|
|
tasks = result.tasks ?? [];
|
|
projects = result.projects ?? [];
|
|
notes = found.notes ?? [];
|
|
selected = 0;
|
|
} catch {
|
|
// A failed search shows nothing rather than an error inside a
|
|
// palette somebody is about to close anyway.
|
|
}
|
|
}, 140);
|
|
});
|
|
|
|
/**
|
|
* Indstillingerne, og hvorfor de ligger øverst.
|
|
*
|
|
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
|
|
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
|
|
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
|
|
* modsat en søgning i noget, man selv har skrevet.
|
|
*/
|
|
let settings = $derived(
|
|
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
|
|
);
|
|
|
|
let results = $derived([
|
|
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
|
|
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
|
|
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
|
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
|
|
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
|
|
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
|
|
// skal ⌘K ikke være stedet, det viser sig.
|
|
...(app.notesOnly ? [] : tasks).map((t) => ({
|
|
kind: 'task',
|
|
id: t.id,
|
|
label: t.content,
|
|
project: t.project_id,
|
|
done: t.completed
|
|
}))
|
|
]);
|
|
|
|
/**
|
|
* Spørg om sine egne noter og opgaver.
|
|
*
|
|
* Bag en tast frem for automatisk: det koster et kald til en model, og de
|
|
* fleste ⌘K er nogen, der leder efter en note, de kender navnet på.
|
|
*/
|
|
async function ask() {
|
|
const question = query.trim();
|
|
if (!question || asking) return;
|
|
asking = true;
|
|
answer = null;
|
|
try {
|
|
answer = await api.aiAsk(question);
|
|
} catch (e) {
|
|
answer = { answer: '', error: humanMessage(e), sources: [] };
|
|
} finally {
|
|
asking = false;
|
|
}
|
|
}
|
|
|
|
function openSource(source) {
|
|
open = false;
|
|
if (source.kind === 'note') return goto(`/noter?note=${source.id}`);
|
|
app.openDetail(source.id);
|
|
}
|
|
|
|
function choose(item) {
|
|
open = false;
|
|
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
|
// op, den ER stedet.
|
|
if (item.kind === 'setting') return goto(item.id);
|
|
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
|
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
|
// lige lukkede.
|
|
if (item.kind === 'note') {
|
|
const q = query.trim();
|
|
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
|
}
|
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
|
}
|
|
|
|
function onkeydown(event) {
|
|
switch (event.key) {
|
|
case 'Escape':
|
|
open = false;
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
selected = Math.min(selected + 1, results.length - 1);
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
selected = Math.max(selected - 1, 0);
|
|
break;
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
// ⌘⏎ spørger, ⏎ åbner det valgte. Den, der leder efter en note, skal
|
|
// ikke vente på en model for at komme hen til den.
|
|
if (event.metaKey || event.ctrlKey) {
|
|
ask();
|
|
break;
|
|
}
|
|
if (results[selected]) choose(results[selected]);
|
|
break;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if open}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
<div class="scrim" onclick={() => (open = false)}>
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label={t('nav.search')}>
|
|
<input
|
|
bind:this={input}
|
|
bind:value={query}
|
|
{onkeydown}
|
|
placeholder={t('nav.searchLong')}
|
|
aria-label={t('nav.search')}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
|
|
{#if query.trim()}
|
|
<div class="askrow">
|
|
<button class="ask" onclick={ask} disabled={asking}>
|
|
{asking ? t('ai.thinking') : t('ai.askThis')}
|
|
</button>
|
|
<kbd>⌘⏎</kbd>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if answer}
|
|
<div class="answer">
|
|
{#if answer.error}
|
|
<p class="bad">{answer.error}</p>
|
|
{:else if answer.answer}
|
|
<p>{answer.answer}</p>
|
|
{#if answer.sources?.length}
|
|
<!-- Hvad svaret står på. En model, der siger "det gjorde du
|
|
den 14.", er kun brugbar, hvis man kan slå op i det, den
|
|
læste det i. -->
|
|
<p class="sources">
|
|
{t('ai.askSources')}
|
|
{#each answer.sources as source, i (source.kind + source.id)}<button
|
|
class="source"
|
|
onclick={() => openSource(source)}>{source.title}</button
|
|
>{i < answer.sources.length - 1 ? ', ' : ''}{/each}
|
|
</p>
|
|
{/if}
|
|
{:else}
|
|
<p class="bad">{t('ai.askNothing')}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if results.length}
|
|
<ul>
|
|
{#each results as item, i (item.kind + item.id)}
|
|
<li>
|
|
<button
|
|
class:selected={i === selected}
|
|
class:done={item.done}
|
|
onclick={() => choose(item)}
|
|
onmouseenter={() => (selected = i)}
|
|
>
|
|
<span class="kind">{t('palette.' + item.kind)}</span>
|
|
<span class="label">{item.label}</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else if query.trim()}
|
|
<p class="empty">{t('nav.noResults')}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgb(0 0 0 / 0.45);
|
|
display: flex;
|
|
justify-content: center;
|
|
/* Spørgsmålsknappen står under feltet og fylder ikke: de fleste ⌘K er nogen,
|
|
der leder efter en note, de kender navnet på, og skal ikke forbi en model
|
|
for at komme derhen. */
|
|
.askrow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s2);
|
|
padding: var(--s1) var(--s2);
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
.ask {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
font: inherit;
|
|
font-size: var(--text-xs);
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ask:disabled {
|
|
color: var(--ink-faint);
|
|
cursor: default;
|
|
}
|
|
|
|
.answer {
|
|
padding: var(--s2);
|
|
border-top: 1px solid var(--line);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.answer p {
|
|
margin: 0 0 var(--s1);
|
|
}
|
|
|
|
.answer .bad {
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.answer .sources {
|
|
margin: 0;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.source {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
font: inherit;
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Not centred: a palette pinned near the top does not jump as results
|
|
appear, and lands where the eye already is after ⌘K. */
|
|
align-items: flex-start;
|
|
padding-top: 12vh;
|
|
z-index: 70;
|
|
}
|
|
|
|
.palette {
|
|
width: min(560px, calc(100vw - var(--s6)));
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
input {
|
|
width: 100%;
|
|
padding: var(--s4);
|
|
background: transparent;
|
|
border: 0;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: var(--text-lg);
|
|
outline: none;
|
|
}
|
|
|
|
input::placeholder {
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: var(--s2);
|
|
max-height: 45vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
li button {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--s3);
|
|
width: 100%;
|
|
padding: var(--s2) var(--s3);
|
|
border-radius: var(--radius);
|
|
text-align: left;
|
|
color: var(--ink);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
li button.selected {
|
|
background: var(--surface);
|
|
}
|
|
|
|
li button.done .label {
|
|
color: var(--ink-faint);
|
|
text-decoration: line-through;
|
|
}
|
|
|
|
.kind {
|
|
flex: none;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
width: 52px;
|
|
}
|
|
|
|
.label {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.empty {
|
|
margin: 0;
|
|
padding: var(--s5);
|
|
text-align: center;
|
|
color: var(--ink-faint);
|
|
font-size: var(--text-sm);
|
|
}
|
|
</style>
|