feat(webapp): wire isParallelSafe in Companion chat + Mission runner

Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:

  isParallelSafe: (name) =>
    AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'

Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.

Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:11:24 +02:00
parent a64a7e39cf
commit 54a12ffd5c
59 changed files with 5629 additions and 218 deletions

View file

@ -0,0 +1,73 @@
<script lang="ts">
import type { BlockRenderProps } from '../types';
import type { RichTextProps } from './schema';
let { block, mode }: BlockRenderProps<RichTextProps> = $props();
const paragraphs = $derived(
block.props.content
.split(/\n{2,}/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
);
const isEdit = $derived(mode === 'edit');
</script>
<section
class="wb-richtext"
class:wb-richtext--left={block.props.align === 'left'}
class:wb-richtext--center={block.props.align === 'center'}
class:wb-richtext--sm={block.props.size === 'sm'}
class:wb-richtext--md={block.props.size === 'md'}
class:wb-richtext--lg={block.props.size === 'lg'}
data-mode={mode}
>
<div class="wb-richtext__inner">
{#if paragraphs.length === 0 && isEdit}
<p class="wb-placeholder">Leerer Text — öffne den Inspector und fang an zu schreiben.</p>
{:else}
{#each paragraphs as paragraph, i (i)}
<p>{paragraph}</p>
{/each}
{/if}
</div>
</section>
<style>
.wb-richtext {
padding: 2rem 1.5rem;
display: flex;
justify-content: center;
}
.wb-richtext__inner {
max-width: 48rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-richtext--left .wb-richtext__inner {
text-align: left;
}
.wb-richtext--center .wb-richtext__inner {
text-align: center;
}
.wb-richtext p {
margin: 0;
line-height: 1.65;
}
.wb-richtext--sm p {
font-size: 0.9375rem;
}
.wb-richtext--md p {
font-size: 1.0625rem;
}
.wb-richtext--lg p {
font-size: 1.25rem;
}
.wb-placeholder {
opacity: 0.35;
font-style: italic;
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import type { BlockInspectorProps } from '../types';
import type { RichTextProps } from './schema';
let { block, onChange }: BlockInspectorProps<RichTextProps> = $props();
</script>
<div class="wb-inspector">
<label class="wb-field">
<span>Text</span>
<textarea
rows="10"
value={block.props.content}
oninput={(e) => onChange({ content: e.currentTarget.value })}
placeholder="Leere Zeile = neuer Absatz. Markdown folgt in M3."
></textarea>
</label>
<div class="wb-row">
<label class="wb-field">
<span>Ausrichtung</span>
<select
value={block.props.align}
onchange={(e) => onChange({ align: e.currentTarget.value as RichTextProps['align'] })}
>
<option value="left">Linksbündig</option>
<option value="center">Zentriert</option>
</select>
</label>
<label class="wb-field">
<span>Schriftgröße</span>
<select
value={block.props.size}
onchange={(e) => onChange({ size: e.currentTarget.value as RichTextProps['size'] })}
>
<option value="sm">Klein</option>
<option value="md">Normal</option>
<option value="lg">Groß</option>
</select>
</label>
</div>
</div>
<style>
.wb-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.wb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wb-field > span {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
letter-spacing: 0.02em;
}
.wb-field textarea,
.wb-field select {
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-family: inherit;
font-size: 0.875rem;
}
.wb-field textarea {
resize: vertical;
min-height: 6rem;
}
.wb-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,19 @@
import type { BlockSpec } from '../types';
import RichText from './RichText.svelte';
import RichTextInspector from './RichTextInspector.svelte';
import { RichTextSchema, RICH_TEXT_DEFAULTS, type RichTextProps } from './schema';
export const richTextBlockSpec: BlockSpec<RichTextProps> = {
type: 'richText',
label: 'Text',
icon: 'text',
category: 'content',
schema: RichTextSchema,
schemaVersion: 1,
defaults: RICH_TEXT_DEFAULTS,
Component: RichText,
Inspector: RichTextInspector,
};
export type { RichTextProps };
export { RichTextSchema, RICH_TEXT_DEFAULTS };

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
export const RichTextSchema = z.object({
content: z.string().max(10_000).default(''),
align: z.enum(['left', 'center']).default('left'),
size: z.enum(['sm', 'md', 'lg']).default('md'),
});
export type RichTextProps = z.infer<typeof RichTextSchema>;
export const RICH_TEXT_DEFAULTS: RichTextProps = {
content: '',
align: 'left',
size: 'md',
};