Skip to content
Closed
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
5 changes: 3 additions & 2 deletions docs/dev-hq/concepts/hq2-concept.html

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/dev-hq/concepts/hq2-studio.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
<a class="skip" href="#main">Zum Inhalt</a>
<header><a class="brand" href="#overview" aria-label="Dev-HQ Übersicht"><img src="dev-hq-mark-v1.png" alt="" width="40" height="40">DEV<span>·</span>HQ <small>Studio</small></a><label class="project-label" for="project">Projekt</label><select id="project" aria-label="Runtime-Projekt"><option value="">Runtime wird geprüft …</option></select><span id="connection" role="status">Verbinden …</span><button id="refresh" class="quiet">Aktualisieren</button><label class="project-label" for="density">Dichte</label><select id="density" aria-label="Dichte" title="Darstellung für alle Arbeitsbereiche"><option value="calm">Komfortabel</option><option value="compact">Kompakt</option></select><button id="theme" aria-label="Dunkles Farbschema einschalten">Dunkel</button></header>
<div class="shell"><nav aria-label="Arbeitsbereiche"><p class="nav-label">Arbeitsbereich</p><button data-view="overview" aria-current="page">Übersicht</button><button data-view="roadmap">Roadmap</button><button data-view="chat">Code-Chat</button><button data-view="teams">Agententeams</button><button data-view="queue">Aufträge & Rückfragen</button><p class="nav-label">Wissen & Steuerung</p><button data-view="lessons">Lessons</button><button data-view="capacity">Kapazität & Routing</button><button data-view="stats">Statistiken</button><button data-view="analysis">Projektanalyse</button><button data-view="extensions">Skills & Plugins</button><button data-view="settings">Harness & Einstellungen</button><div class="nav-foot">Ein Auftrag. Ein Worktree.<br>Jede Aussage mit Quelle.<span id="last-read"></span></div></nav>
<main id="main" tabindex="-1"><div id="notice" role="status" hidden></div><div id="page"></div></main></div>
<main id="main" tabindex="-1"><div id="notice" role="status" hidden></div><section class="design-decision" id="design-decision"><div><strong>Dein Urteil ist gefragt</strong><p>Die Demo bestimmt die visuelle Richtung, bevor sie in die installierte App übernommen wird.</p></div><button id="review" type="button">Design prüfen</button></section><div id="page"></div></main></div>
<dialog id="confirm" aria-labelledby="confirm-title"><form method="dialog"><h2 id="confirm-title">Aktion prüfen</h2><pre id="confirm-detail"></pre><p>Dieser Schritt wird an die echte Runtime gesendet. Bestehende Budget- und Freigaberegeln gelten weiter.</p><div class="actions"><button value="cancel">Abbrechen</button><button value="ok" class="primary">Bestätigen</button></div></form></dialog>
<div class="guidance" id="guidance"><p><strong>Kurzer Einstieg</strong> · Wähle links einen Bereich. „Quelle ansehen“ ersetzt rechts die Assistenz. Im Code-Chat bleiben Beratung und aktive Arbeit getrennt.</p><button id="hide-guide" type="button" aria-label="Anleitung ausblenden">Ausblenden</button></div>
<script src="studio-model.js"></script><script src="studio-roadmap.js"></script><script src="studio-workspace.js"></script>
</body></html>
11 changes: 11 additions & 0 deletions docs/dev-hq/concepts/studio-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ aside .panel{background:var(--soft)}
html[data-chat-appearance=deepseek] .chat-workspace .composer textarea{border-radius:14px}
}
#notice.error{background:var(--surface);border-color:var(--error)}
.design-decision{margin:0 0 20px;border:1px solid var(--warn);background:var(--amber-wash);padding:18px 20px;display:flex;align-items:center;gap:20px;justify-content:space-between;border-radius:var(--radius)}
.design-decision p{margin:3px 0 0;color:var(--muted)}.design-decision button{border-color:var(--warn);background:var(--surface);white-space:nowrap}
.guidance{position:fixed;z-index:20;bottom:18px;left:244px;right:24px;max-width:880px;display:flex;align-items:center;gap:18px;background:var(--ink);color:var(--surface);padding:13px 16px;border-radius:var(--radius);box-shadow:var(--shadow)}
.guidance p{margin:0;flex:1;font-size:12px}.guidance button{background:transparent;color:inherit;border-color:currentColor;white-space:nowrap}
#page[data-view=stats]>label{max-width:240px}
#skill-filter{width:100%;margin-bottom:12px}.list-select .check{padding:8px 0;border-bottom:1px solid var(--line);align-items:flex-start}.list-select .check input{margin-top:5px}
@media(min-width:761px) and (max-width:1200px){.shell{grid-template-columns:200px minmax(0,1fr)}.brand{min-width:166px}.layout{grid-template-columns:1fr}.chat-layout aside{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.two{gap:16px}header{gap:10px;padding-inline:16px}#connection{max-width:180px}main{padding:24px}.project-empty{align-items:flex-start}.project-empty button{flex-shrink:1}}
Expand Down Expand Up @@ -183,4 +187,11 @@ aside .panel{background:var(--soft)}

@media (min-width: 761px) and (max-width: 1200px) {
.chat-layout aside { gap: var(--studio-gap); }
.guidance { left: 220px; }
}

@media (max-width: 760px) {
.design-decision, .guidance { display: block; }
.design-decision button, .guidance button { margin-top: var(--space-3); }
.guidance { left: var(--space-3); right: var(--space-3); bottom: var(--space-3); }
}
3 changes: 3 additions & 0 deletions docs/dev-hq/concepts/studio-workspace.js

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

32 changes: 32 additions & 0 deletions scripts/lib/hq-studio-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@ import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const base = new URL('../../docs/dev-hq/concepts/', import.meta.url);
const tick = () => new Promise(resolve => setImmediate(resolve));

test('intro bar and design decision preserve the approved accessible token contract', async t => {
const studioHtml = readFileSync(new URL('hq2-studio.html', base), 'utf8');
const conceptHtml = readFileSync(new URL('hq2-concept.html', base), 'utf8');
const css = readFileSync(new URL('studio-workspace.css', base), 'utf8');
const intro = 'Wähle links einen Bereich. „Quelle ansehen“ ersetzt rechts die Assistenz. Im Code-Chat bleiben Beratung und aktive Arbeit getrennt.';
const decision = 'Die Demo bestimmt die visuelle Richtung, bevor sie in die installierte App übernommen wird.';

for (const html of [studioHtml, conceptHtml]) {
assert.match(html, new RegExp(intro.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(html, /<button[^>]+id="hide-guide"[^>]*>Ausblenden<\/button>/);
assert.match(html, /<strong>Dein Urteil ist gefragt<\/strong>/);
assert.match(html, new RegExp(decision));
assert.match(html, /<button[^>]+id="review"[^>]*>Design prüfen<\/button>/);
}

for (const selector of ['.guidance', '.design-decision']) {
const rule = css.match(new RegExp(`${selector.replace('.', '\\.')}\\{([^}]*)\\}`))?.[1];
assert.ok(rule, `${selector} has a shared Studio rule`);
assert.match(rule, /var\(--/);
assert.doesNotMatch(rule, /#[0-9a-f]{3,8}\b/i);
}

const f = await fixture();
t.after(() => f.w.close());
assert.equal(f.q('#hide-guide').tagName, 'BUTTON');
assert.equal(f.q('#review').tagName, 'BUTTON');
assert.equal(f.q('#design-decision').hidden, false);
f.q('#hide-guide').click();
assert.equal(f.q('#guidance').hidden, true);
});

async function fixture({ density, appearance, storageUnavailable = false } = {}) {
const html = readFileSync(new URL('hq2-studio.html', base), 'utf8').replace('<head>', '<head><meta name="hq-session" content="test-session">');
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'http://127.0.0.1:4187/concepts/hq2-studio.html' });
Expand Down
19 changes: 17 additions & 2 deletions src-tauri/src/process_capture/checkpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,13 @@ impl Gate {
self.pending.pop_front();
Ok(Some(stage))
}
Ok(Err(_)) | Err(TryRecvError::Disconnected) => {
Ok(Err(reason)) => {
self.failed = true;
Err("checkpoint persistence unconfirmed".into())
Err(format!("checkpoint persistence unconfirmed: {reason}"))
}
Err(TryRecvError::Disconnected) => {
self.failed = true;
Err("checkpoint persistence unconfirmed: channel disconnected".into())
}
Err(TryRecvError::Empty) => Ok(None),
}
Expand Down Expand Up @@ -131,6 +135,17 @@ mod tests {
assert_eq!(gate.poll().unwrap(), None);
}
#[test]
fn a_rejected_acknowledgement_keeps_its_reason() {
let (tx, rx) = mpsc::sync_channel(1);
let mut gate = Gate::new(binding(), Some(tx));
gate.submit(Stage::Launch, Value::Null).unwrap();
rx.recv().unwrap().acknowledge(Err("x".into())).unwrap();

let error = gate.poll().unwrap_err();
assert!(error.starts_with("checkpoint persistence unconfirmed"));
assert!(error.contains('x'));
}
#[test]
fn failed_or_lost_checkpoint_cannot_be_replaced_by_later_success() {
for failed in [false, true] {
let (tx, rx) = mpsc::sync_channel(4);
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/process_capture/windows_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1831,7 +1831,7 @@ fn self_test_host_checkpoints(
match (rejected, result) {
(None, Ok(_)) => Ok(()),
(Some(_), Err(error))
if error == "checkpoint persistence unconfirmed"
if error.starts_with("checkpoint persistence unconfirmed")
|| error == "checkpoint consumer unavailable" =>
{
Ok(())
Expand Down
21 changes: 18 additions & 3 deletions src-tauri/src/workers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4067,7 +4067,17 @@ mod tests {
.await
.unwrap();
assert!(drained.running.is_empty());
assert_eq!(drained.unresolved.is_empty(), !fail_finalization);
assert_eq!(
drained.unresolved.is_empty(),
!fail_finalization,
"unresolved sessions: {:?}; completions: {:?}",
drained.unresolved,
drained
.completions
.iter()
.map(|entry| (&entry.session_id, &entry.result))
.collect::<Vec<_>>()
);
assert_eq!(
manager.live_session_ids().unwrap().is_empty(),
!fail_finalization
Expand Down Expand Up @@ -4227,8 +4237,13 @@ mod tests {
// launch stuck forever in `spawning` / unresolved.
assert!(
drained.unresolved.is_empty(),
"native launch left unresolved instead of reconciled: {:?}",
drained.unresolved
"native launch left unresolved instead of reconciled: {:?}; completions: {:?}",
drained.unresolved,
drained
.completions
.iter()
.map(|entry| (&entry.session_id, &entry.result))
.collect::<Vec<_>>()
);
let launch = fx.store.development_launch(&run_id).await.unwrap().unwrap();
// Owner decision: a distinct state, never `exited` (whose readers
Expand Down
39 changes: 37 additions & 2 deletions src/components/EmergencyStop.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";

import EmergencyStop from "./EmergencyStop";

Expand All @@ -10,6 +10,7 @@ const ipc = vi.hoisted(() => ({
vi.mock("../lib/ipc", () => ({ ...ipc, describeError: (e: unknown) => String(e) }));

describe("EmergencyStop", () => {
afterEach(() => vi.useRealTimers());
beforeEach(() => {
ipc.getEmergencyStop.mockReset();
ipc.setEmergencyStop.mockReset();
Expand Down Expand Up @@ -40,4 +41,38 @@ describe("EmergencyStop", () => {
expect(screen.getByRole("status")).toHaveTextContent(/Stillstand ist nicht bestätigt/);
expect(screen.queryByText(/alle Agenten sind beendet/)).toBeNull();
});

it("shows a stop raised through the API while settings stays open", async () => {
ipc.getEmergencyStop.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
render(<EmergencyStop />);
expect(await screen.findByRole("button", { name: "Not-Aus auslösen" })).toBeTruthy();
fireEvent.focus(window);
expect(await screen.findByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy();
});

it("does not let an older refresh undo a stop raised in this view", async () => {
let resolveRefresh: (active: boolean) => void = () => {};
ipc.getEmergencyStop
.mockResolvedValueOnce(false)
.mockImplementationOnce(() => new Promise((resolve) => { resolveRefresh = resolve; }));
ipc.setEmergencyStop.mockResolvedValue(undefined);
render(<EmergencyStop />);
const raise = screen.getByRole("button", { name: "Not-Aus auslösen" });
await waitFor(() => expect(raise).toBeEnabled());
fireEvent.focus(window);
fireEvent.click(raise);
expect(await screen.findByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy();
await act(async () => resolveRefresh(false));
expect(screen.getByRole("button", { name: "Not-Aus aufheben" })).toBeTruthy();
});

it("polls the safety state every five seconds while visible", async () => {
vi.useFakeTimers();
ipc.getEmergencyStop.mockResolvedValue(false);
render(<EmergencyStop />);
await act(async () => undefined);
expect(ipc.getEmergencyStop).toHaveBeenCalledOnce();
await act(async () => vi.advanceTimersByTimeAsync(5000));
expect(ipc.getEmergencyStop).toHaveBeenCalledTimes(2);
});
});
36 changes: 29 additions & 7 deletions src/components/EmergencyStop.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

import { describeError, getEmergencyStop, setEmergencyStop } from "../lib/ipc";
import { useRefreshOnResume } from "../lib/useRefreshOnResume";

const POLL_INTERVAL_MS = 5000;

/**
* Not-Aus: stops every running agent (within 10 s) and blocks new dispatches
Expand All @@ -11,25 +14,44 @@ export default function EmergencyStop() {
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const request = useRef(0);

const refresh = useCallback(() => {
const mine = ++request.current;
void getEmergencyStop()
.then((value) => {
if (request.current !== mine) return;
setActive(value === true);
if (!value) setConfirmed(false);
})
.catch(() => {
if (request.current === mine) setActive(true);
});
}, []);

useRefreshOnResume(refresh);

useEffect(() => {
let alive = true;
getEmergencyStop()
.then((value) => alive && setActive(value === true))
.catch(() => alive && setActive(true));
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") refresh();
}, POLL_INTERVAL_MS);
return () => {
alive = false;
request.current += 1;
window.clearInterval(timer);
};
}, []);
}, [refresh]);

async function change(next: boolean) {
request.current += 1;
setBusy(true);
setError(null);
try {
await setEmergencyStop(next);
request.current += 1;
setActive(next);
setConfirmed(next);
} catch (e) {
request.current += 1;
setError(describeError(e));
setConfirmed(false);
// The barrier is written before the processes are ended: after a
Expand Down
Loading
Loading