Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^2.1.9"
},
"dependencies": {
"marked": "^17.0.4"
}
}
92 changes: 74 additions & 18 deletions apps/web/src/routes/chat/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api.js';
import type { ContentDetail } from '$lib/api.js';
import { onMount } from 'svelte';
import { afterUpdate, onMount } from 'svelte';
import { marked } from 'marked';

let item: ContentDetail | null = null;
let messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
// Rendered HTML for completed assistant messages (populated after stream ends)
let renderedHtml: string[] = [];

let bottomAnchor: HTMLElement;
afterUpdate(() => { bottomAnchor?.scrollIntoView({ behavior: 'instant' }); });

let input = '';
let loading = false;
let finishing = false;
Expand All @@ -28,16 +35,32 @@ onMount(async () => {
{ role: 'assistant', content: `Continuing from last session:\n\n${item.session_summary}` },
];
}
renderedHtml = messages.map((m) =>
m.role === 'assistant' ? String(marked.parse(m.content)) : ''
);
});

async function save() {
if (messages.length === 0) return;
saving = true;
error = '';
try {
const conversation = messages.map((m) => `${m.role}: ${m.content}`).join('\n');
const { summary } = await api.summarise(conversation);
await api.patch(id, { session_summary: summary });
// Format conversation as markdown and append to existing document body
const sessionMd = [
`## Session — ${new Date().toLocaleString()}`,
'',
...messages.map((m) =>
m.role === 'user' ? `**You:** ${m.content}` : `**Minime:** ${m.content}`
),
].join('\n\n');
const currentBody = (item?.body ?? '').trimEnd();
const newBody = currentBody ? `${currentBody}\n\n${sessionMd}` : sessionMd;
await api.patch(id, { session_summary: summary, body: newBody });
savedAt = new Date();
} catch {
error = 'Failed to save. Please try again.';
} finally {
saving = false;
}
Expand Down Expand Up @@ -76,27 +99,32 @@ async function send() {
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const eventLine = part.split('\n').find((l) => l.startsWith('event:'));
const dataLine = part.split('\n').find((l) => l.startsWith('data:'));
if (!dataLine) continue;
const data = dataLine.slice(5).trim();
const lines = part.split('\n');
const eventLine = lines.find((l) => l.startsWith('event:'));
const dataLines = lines.filter((l) => l.startsWith('data:'));
if (!dataLines.length) continue;
// SSE encodes \n in data as multiple "data:" lines — reassemble them
const data = dataLines.map((l) => l.slice(6)).join('\n');
if (eventLine?.includes('context')) {
try {
contextTitles = JSON.parse(data).map((e: { title: string }) => e.title);
} catch {
/* ignore */
}
} else if (data !== '[DONE]') {
} else if (data.trimEnd() !== '[DONE]') {
assistantMsg.content += data;
messages = [...messages];
}
}
}
} catch {
error = 'Failed to send message. Please try again.';
messages = messages.slice(0, -1); // remove empty assistant message
messages = messages.slice(0, -1);
} finally {
loading = false;
renderedHtml = messages.map((m) =>
m.role === 'assistant' ? String(marked.parse(m.content)) : ''
);
}
}

Expand Down Expand Up @@ -143,18 +171,32 @@ async function finish() {
}
</script>

<style>
.md p { margin: 0 0 0.5em; }
.md p:last-child { margin-bottom: 0; }
.md ul, .md ol { margin: 0 0 0.5em 1.2em; padding: 0; }
.md li { margin-bottom: 0.2em; }
.md code { background: #2a2a2a; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.9em; }
.md pre { background: #2a2a2a; padding: 8px; border-radius: 6px; overflow-x: auto; margin: 0 0 0.5em; }
.md pre code { background: none; padding: 0; }
.md strong { color: #e2e8f0; }
.md h1, .md h2, .md h3 { margin: 0.5em 0 0.25em; font-size: 1em; font-weight: 600; }
.md pre { overflow-x: auto; max-width: 100%; }
.md * { overflow-wrap: break-word; }
</style>

<div style="max-width:480px;margin:0 auto;display:flex;flex-direction:column;height:100vh;font-family:system-ui;background:#0f0f0f;color:#fff">
<header style="padding:12px 16px;border-bottom:1px solid #222;display:flex;align-items:center;gap:8px">
<a href="/" style="color:#aaa;text-decoration:none">←</a>
<span style="flex:1;font-weight:500;font-size:14px">{item?.title ?? '...'}</span>
{#if item}
{#if item.type === 'idea'}
<button on:click={promote} disabled={promoting || finishing} style="font-size:12px;background:#1a2a3a;color:#a78bfa;border:1px solid #a78bfa;padding:4px 10px;border-radius:6px;cursor:pointer">{promoting ? '...' : '→ Plan'}</button>
<button on:click={promote} disabled={promoting || finishing} title="Promote to Plan — creates a new plan document seeded from this idea and its session notes" style="font-size:12px;background:#1a2a3a;color:#a78bfa;border:1px solid #a78bfa;padding:4px 10px;border-radius:6px;cursor:pointer">{promoting ? '...' : '→ Plan'}</button>
{/if}
<button on:click={park} disabled={parking || finishing} style="font-size:12px;background:#2a2a1a;color:#facc15;border:1px solid #facc15;padding:4px 10px;border-radius:6px;cursor:pointer">{parking ? '...' : 'Park'}</button>
<button on:click={save} disabled={saving || finishing || parking} style="font-size:12px;background:#1a1a2a;color:#94a3b8;border:1px solid #94a3b8;padding:4px 10px;border-radius:6px;cursor:pointer">{saving ? '...' : savedAt ? 'Saved ✓' : 'Save'}</button>
<button on:click={park} disabled={parking || finishing} title="Park — saves the conversation then marks this as paused. Come back to it later from the home screen." style="font-size:12px;background:#2a2a1a;color:#facc15;border:1px solid #facc15;padding:4px 10px;border-radius:6px;cursor:pointer">{parking ? '...' : 'Park'}</button>
<button on:click={save} disabled={saving || finishing || parking} title="Save — appends this conversation to the document and generates a session summary. Does not commit to GitHub." style="font-size:12px;background:#1a1a2a;color:#94a3b8;border:1px solid #94a3b8;padding:4px 10px;border-radius:6px;cursor:pointer">{saving ? '...' : savedAt ? 'Saved ✓' : 'Save'}</button>
{#if item.pr}
<button on:click={finish} disabled={finishing || parking} style="font-size:12px;background:#1a3a1a;color:#4ade80;border:1px solid #4ade80;padding:4px 10px;border-radius:6px;cursor:pointer">{finishing ? '...' : 'Commit ✓'}</button>
<button on:click={finish} disabled={finishing || parking} title="Commit — merges the PR into main, permanently storing this item in your knowledge base. Cannot be undone." style="font-size:12px;background:#1a3a1a;color:#4ade80;border:1px solid #4ade80;padding:4px 10px;border-radius:6px;cursor:pointer">{finishing ? '...' : 'Commit ✓'}</button>
{/if}
{/if}
</header>
Expand All @@ -166,16 +208,30 @@ async function finish() {
{/if}

<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px">
{#each messages as msg}
<div style="
align-self:{msg.role==='user'?'flex-end':'flex-start'};
max-width:85%;background:{msg.role==='user'?'#1a3a5c':'#1a1a1a'};
padding:10px 14px;border-radius:12px;font-size:14px;white-space:pre-wrap
">{msg.content || (loading && msg.role === 'assistant' ? '…' : msg.content)}</div>
{#each messages as msg, i}
<div
class={msg.role === 'assistant' ? 'md' : ''}
style="
align-self:{msg.role==='user'?'flex-end':'flex-start'};
max-width:85%;background:{msg.role==='user'?'#1a3a5c':'#1a1a1a'};
padding:10px 14px;border-radius:12px;font-size:14px;overflow-wrap:break-word;min-width:0
"
>
{#if msg.role === 'assistant'}
{#if renderedHtml[i]}
{@html renderedHtml[i]}
{:else}
<span style="white-space:pre-wrap;color:{msg.content ? 'inherit' : '#666'}">{msg.content || '…'}</span>
{/if}
{:else}
{msg.content}
{/if}
</div>
{/each}
{#if error}
<p role="alert" style="color:#f87171;font-size:13px;padding:0 4px">{error}</p>
{/if}
<div bind:this={bottomAnchor}></div>
</div>

<div style="padding:12px 16px;border-top:1px solid #222;display:flex;gap:8px">
Expand Down
6 changes: 6 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ import { defineConfig } from 'vite';

export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8744',
'/auth': 'http://localhost:8744',
},
},
});
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading