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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/target
tests/e2e/.host-ca-certificates/*
!tests/e2e/.host-ca-certificates/.gitkeep
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions crates/giskard-server/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@
.remove-project-dialog { width:min(520px,92vw); }
.remove-project-dialog p { margin:0 0 10px; }
.remove-project-path { padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:var(--panel2); font-size:12px; word-break:break-all; }
.remove-thread-dialog { width:min(520px,92vw); }
.remove-thread-dialog p { margin:0 0 10px; }
.code-dialog { width:min(1120px,94vw); height:min(860px,90vh); }
.code-head { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; gap:10px; align-items:flex-start; }
.code-head-main { flex:1; min-width:0; }
Expand Down
144 changes: 131 additions & 13 deletions crates/giskard-server/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const MAX_TOTAL_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const MAX_ATTACHMENT_NAME_BYTES = 255;
const MAX_ATTACHMENT_MIME_BYTES = 127;
const THREAD_DELETE_TIMEOUT_MS = 30000;
let state = {
projectId:null, threadId:null, mode:"build", ws:null, wsStatus:"closed", wsConnectId:0,
wsReconnectTimer:null, wsReconnectAttempt:0, wsStatusDetail:"WebSocket disconnected",
Expand Down Expand Up @@ -95,7 +96,8 @@ let state = {
subagentImports:new Map(), projectThreads:new Map(), threadIndex:new Map(),
lastNotificationPromptNoticeAt:0, swRegistration:null, pendingAttachments:[],
attachmentGeneration:0, pendingAttachmentOperations:new Map(),
collapsedProjects:new Set(loadCollapsedProjects()), pendingRemoveProject:null, projectDirs:{}
collapsedProjects:new Set(loadCollapsedProjects()), pendingRemoveProject:null,
pendingRemoveThread:null, removeThreadRequestSeq:0, projectDirs:{}
};
let attachmentIngestQueue = Promise.resolve();
const activeAttachmentReaders = new Set();
Expand All @@ -114,17 +116,45 @@ const EFFORT_OPTIONS = [
];
setInterval(updateRunningCommandDurations, 1000);

async function api(method, path, body) {
async function api(method, path, body, options) {
const opts = { method, headers:{} };
const timeoutMs = options && Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 0;
let timeoutId = null;
let timedOut = false;
let timeoutPromise = null;
if (body !== undefined) { opts.headers["Content-Type"]="application/json"; opts.body=JSON.stringify(body); }
const r = await fetch(path, opts);
if (!r.ok) {
const err = new Error((await r.text()) || `HTTP ${r.status}`);
err.status = r.status;
throw err;
if (timeoutMs && typeof AbortController === "function") {
const controller = new AbortController();
opts.signal = controller.signal;
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
} else if (timeoutMs) {
timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
reject(new Error(`Request timed out after ${Math.round(timeoutMs / 1000)} seconds.`));
}, timeoutMs);
});
}
try {
const fetchPromise = fetch(path, opts);
if (timeoutPromise) fetchPromise.catch(() => {});
const r = timeoutPromise ? await Promise.race([fetchPromise, timeoutPromise]) : await fetchPromise;
if (!r.ok) {
const err = new Error((await r.text()) || `HTTP ${r.status}`);
err.status = r.status;
throw err;
}
const ct = r.headers.get("content-type")||"";
return ct.includes("json") ? r.json() : r.text();
} catch (e) {
if (timedOut) throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
throw e;
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
const ct = r.headers.get("content-type")||"";
return ct.includes("json") ? r.json() : r.text();
}
function apiFailureMessage(e) {
const msg = e && e.message ? e.message : String(e);
Expand Down Expand Up @@ -1386,9 +1416,87 @@ async function deleteThread(pid, tid, title) {
const cascade = descendants.length
? `, its ${descendants.length} linked sub-agent thread${descendants.length === 1 ? "" : "s"}, and all corresponding Codex threads`
: " and its corresponding Codex thread";
if (!confirm(`Permanently delete thread "${title}"${cascade}? This cannot be undone.`)) return;
// Open the modal instead of a native confirm() so the user gets the same confirmation card
// style as project removal. The cascade description is mirrored into the dialog content.
openRemoveThreadModal(pid, tid, title, cascade);
}

function openRemoveThreadModal(pid, tid, title, cascade) {
const opener = threadRowForId(tid)?.closest(".thread-row")?.querySelector(".thread-menu-btn")
|| (document.activeElement instanceof HTMLElement ? document.activeElement : null);
closeDrawers();
closeThreadMenus();
state.pendingRemoveThread = {
pid,
tid,
title,
descendants: threadDescendantIds(pid, tid),
opener,
deleting:false,
requestSeq:0,
};
setRemoveThreadDeleting(false);
$("removeThreadErr").textContent = "";
$("removeThreadName").textContent = title || "this thread";
$("removeThreadCascade").textContent = cascade || "";
$("removeThreadModal").classList.add("open");
$("removeThreadConfirm").focus();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function setRemoveThreadDeleting(deleting) {
const modal = $("removeThreadModal");
$("removeThreadConfirm").disabled = deleting;
$("removeThreadCancel").disabled = deleting;
modal.setAttribute("aria-busy", deleting ? "true" : "false");
}

function removeThreadFallbackFocus(pending) {
const rowButton = pending && pending.tid
? threadRowForId(pending.tid)?.closest(".thread-row")?.querySelector(".thread-menu-btn")
: null;
return rowButton
|| (state.threadId && threadRowForId(state.threadId)?.closest(".thread-row")?.querySelector(".thread-menu-btn"))
|| $("newProj")
|| $("btnMenu");
}

function restoreRemoveThreadFocus(pending) {
const target = pending && pending.opener && pending.opener.isConnected
? pending.opener
: removeThreadFallbackFocus(pending);
if (target && target.isConnected && typeof target.focus === "function") target.focus();
}

function closeRemoveThreadModal(options) {
const force = options && options.force;
const restoreFocus = !options || options.restoreFocus !== false;
const pending = state.pendingRemoveThread;
if (pending && pending.deleting && !force) return false;
$("removeThreadModal").classList.remove("open");
setRemoveThreadDeleting(false);
state.pendingRemoveThread = null;
if (restoreFocus) restoreRemoveThreadFocus(pending);
return true;
}

$("removeThreadCancel").onclick = closeRemoveThreadModal;
$("removeThreadModal").addEventListener("click", (e) => {
if (e.target === $("removeThreadModal")) closeRemoveThreadModal();
});

$("removeThreadConfirm").onclick = async () => {
const pending = state.pendingRemoveThread;
if (!pending || !pending.pid || !pending.tid) return;
const { pid, tid, descendants } = pending;
pending.deleting = true;
pending.requestSeq = ++state.removeThreadRequestSeq;
const requestSeq = pending.requestSeq;
setRemoveThreadDeleting(true);
$("removeThreadErr").textContent = "";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await api("DELETE", `/api/projects/${pid}/threads/${tid}`);
await api("DELETE", `/api/projects/${pid}/threads/${tid}`, undefined, {
timeoutMs: THREAD_DELETE_TIMEOUT_MS,
});
// The server cascades to descendants it discovered itself, which can include children this
// client never listed. Decide from the refreshed authoritative list whether the active view
// was deleted; keep the pre-request set as a fallback in case the sidebar reload fails
Expand All @@ -1405,8 +1513,17 @@ async function deleteThread(pid, tid, title) {
) {
clearThreadView(activeThread);
}
pending.deleting = false;
closeRemoveThreadModal({ force:true });
} catch (e) {
notice("Delete thread failed: " + e.message, "error");
if (state.pendingRemoveThread === pending && pending.requestSeq === requestSeq) {
$("removeThreadErr").textContent = "Delete thread failed: " + apiFailureMessage(e);
}
} finally {
if (state.pendingRemoveThread === pending && pending.requestSeq === requestSeq) {
pending.deleting = false;
setRemoveThreadDeleting(false);
}
}
}

Expand Down Expand Up @@ -7455,7 +7572,8 @@ $("btnMenu").onclick = () => toggleDrawer("left");
$("backdrop").onclick = closeDrawers;
document.addEventListener("keydown", (e) => {
if (e.key!=="Escape") return;
if ($("codeOverlay").classList.contains("open")) closeCodeOverlay();
if ($("removeThreadModal").classList.contains("open")) closeRemoveThreadModal();
else if ($("codeOverlay").classList.contains("open")) closeCodeOverlay();
else {
closeSettingsMenu();
closeModelPicker();
Expand Down
25 changes: 22 additions & 3 deletions crates/giskard-server/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ <h2>New project</h2>

<!-- Remove-project confirmation. This removes only Giskard's saved project lifecycle data; it
never touches the source/workspace directory. -->
<div class="overlay" id="removeProjectModal">
<div class="overlay" id="removeProjectModal" role="dialog" aria-modal="true" aria-labelledby="removeProjectTitle">
<div class="dialog remove-project-dialog">
<h2>Remove project from Giskard?</h2>
<h2 id="removeProjectTitle">Remove project from Giskard?</h2>
<div class="content">
<p>
This removes <strong id="removeProjectName">this project</strong> from Giskard, including
Expand All @@ -238,13 +238,32 @@ <h2>Remove project from Giskard?</h2>
<div class="remove-project-path mono" id="removeProjectDir"></div>
</div>
<div class="foot">
<span class="err" id="removeProjectErr"></span>
<span class="err" id="removeProjectErr" role="alert" aria-live="assertive"></span>
<button id="removeProjectCancel">Cancel</button>
<button class="danger" id="removeProjectConfirm">Remove from Giskard</button>
</div>
</div>
</div>

<!-- Delete-thread confirmation. Deleting a thread cascades to its linked sub-agent threads and
the corresponding Codex threads managed by the harness; this cannot be undone. -->
<div class="overlay" id="removeThreadModal" role="dialog" aria-modal="true" aria-labelledby="removeThreadTitle">
<div class="dialog remove-thread-dialog">
<h2 id="removeThreadTitle">Delete thread?</h2>
<div class="content">
<p>
Permanently delete thread <strong id="removeThreadName">this thread</strong><span id="removeThreadCascade"></span>?
</p>
<p class="muted">This cannot be undone.</p>
</div>
<div class="foot">
<span class="err" id="removeThreadErr" role="alert" aria-live="assertive"></span>
<button id="removeThreadCancel">Cancel</button>
<button class="danger" id="removeThreadConfirm">Delete thread</button>
</div>
</div>
</div>

<!-- Source/code overlay opened from linkified transcript paths. Highlighting and downloads are
served by the backend so the browser does not need a JS highlighter or filesystem access. -->
<div class="overlay" id="codeOverlay">
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/.host-ca-certificates/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

11 changes: 10 additions & 1 deletion tests/e2e/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ FROM rust:1-slim-bookworm AS builder
WORKDIR /src

# ring (via rustls) needs a C toolchain and perl to build.
COPY tests/e2e/.host-ca-certificates/ /usr/local/share/ca-certificates/giskard-host/
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential pkg-config perl \
&& apt-get install -y --no-install-recommends build-essential ca-certificates pkg-config perl \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY . .
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENV CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt
RUN cargo build --release -p giskard-server --bin giskard-server-replay

# ---- Stage 2: run Playwright ----
Expand All @@ -26,6 +30,11 @@ RUN cargo build --release -p giskard-server --bin giskard-server-replay
FROM mcr.microsoft.com/playwright:v1.56.0-jammy
WORKDIR /e2e

COPY tests/e2e/.host-ca-certificates/ /usr/local/share/ca-certificates/giskard-host/
RUN update-ca-certificates
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt

COPY --from=builder /src/target/release/giskard-server-replay /usr/local/bin/giskard-server-replay

# Install JS deps first (better layer caching), then copy the tests.
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ tests/e2e/run.sh --reporter=line

The HTML report lands in `tests/e2e/playwright-report/` on your host.

If your local network intercepts TLS, put any additional host CA certificates as `.crt` files in
`tests/e2e/.host-ca-certificates/` before running the Docker-based tests. The directory is optional:
CI and ordinary local environments build with only the committed `.gitkeep`, while any local
certificates in that hidden directory are copied into the e2e image trust store and ignored by git.

## Run it without Docker (optional, for UI development)

If you already have Node and the matching Playwright browsers, you can iterate faster by pointing
Expand Down
Loading
Loading