@@ -549,7 +549,48 @@ def init_db():
|
||||
["owner_kind", "owner_id", "scope", "lang"],
|
||||
)
|
||||
gateway_usage_ledger = get_table("gateway_usage_ledger")
|
||||
for column, example in (("ttft_ms", 0.0), ("inter_token_ms", 0.0)):
|
||||
for column, example in (
|
||||
("created_at", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("backend", ""),
|
||||
("endpoint", ""),
|
||||
("requested_model", ""),
|
||||
("model", ""),
|
||||
("status_code", 0),
|
||||
("success", 0),
|
||||
("error_category", ""),
|
||||
("upstream_latency_ms", 0.0),
|
||||
("gateway_overhead_ms", 0.0),
|
||||
("queue_wait_ms", 0.0),
|
||||
("connect_ms", 0.0),
|
||||
("total_latency_ms", 0.0),
|
||||
("prompt_tokens", 0),
|
||||
("completion_tokens", 0),
|
||||
("cache_hit_tokens", 0),
|
||||
("cache_miss_tokens", 0),
|
||||
("reasoning_tokens", 0),
|
||||
("total_tokens", 0),
|
||||
("tokens_per_second", 0.0),
|
||||
("context_window", 0),
|
||||
("context_utilization", 0.0),
|
||||
("cost_usd", 0.0),
|
||||
("input_cost_usd", 0.0),
|
||||
("output_cost_usd", 0.0),
|
||||
("native_cost", 0),
|
||||
("stream_requested", 0),
|
||||
("temperature", 0.0),
|
||||
("top_p", 0.0),
|
||||
("max_tokens", 0),
|
||||
("has_tools", 0),
|
||||
("retries_attempted", 0),
|
||||
("retry_succeeded", 0),
|
||||
("circuit_open", 0),
|
||||
("user_agent", ""),
|
||||
("app_reference", ""),
|
||||
("ttft_ms", 0.0),
|
||||
("inter_token_ms", 0.0),
|
||||
):
|
||||
if not gateway_usage_ledger.has_column(column):
|
||||
gateway_usage_ledger.create_column_by_example(column, example)
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
||||
|
||||
@@ -37,7 +37,10 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
title="Read workspace",
|
||||
summary=(
|
||||
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||
"for your workspace on this project."
|
||||
"for your workspace on this project. The phase (stopped, starting, ready, "
|
||||
"stopping, crashed, suspended) is derived from the desired state, the "
|
||||
"container status and a live probe of the editor port, so editor_ready "
|
||||
"is true only when the editor will actually open."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
@@ -51,7 +54,12 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||
"workspace": {
|
||||
"uid": "INSTANCE_UID",
|
||||
"owner_uid": "USER_UID",
|
||||
"status": "running",
|
||||
"desired_state": "running",
|
||||
"phase": "ready",
|
||||
"phase_label": "Ready",
|
||||
"editor_ready": True,
|
||||
"suspended": False,
|
||||
"tunnel_name": "brave-otter",
|
||||
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||
@@ -131,7 +139,7 @@ arrives as a `workspace` notification and states exactly what happens next and w
|
||||
"terminal_font_size": 13,
|
||||
"zoom_level": 0,
|
||||
"layout": "standard",
|
||||
"panel_preset": "tall",
|
||||
"panel_preset": "normal",
|
||||
"boot_agent": "dpc",
|
||||
"boot_shell": True,
|
||||
"window_mode": "tab",
|
||||
|
||||
@@ -311,12 +311,18 @@ async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||
if denial is not None:
|
||||
return denial
|
||||
if instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
return Response(
|
||||
"this workspace is suspended", status_code=403, media_type="text/plain"
|
||||
)
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return Response("this workspace is not running", status_code=409)
|
||||
return Response(
|
||||
"this workspace is not running", status_code=409, media_type="text/plain"
|
||||
)
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
return Response("the editor has no reachable port", status_code=502)
|
||||
return Response(
|
||||
"the editor has no reachable port", status_code=502, media_type="text/plain"
|
||||
)
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||
|
||||
@@ -188,14 +188,11 @@ async def projects_page(
|
||||
)
|
||||
|
||||
def _editor_launch(project: dict, user: dict) -> dict:
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import editor, provision
|
||||
|
||||
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
if not instance or instance.get("suspended_at"):
|
||||
return blank
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
if not instance or not provision.editor_ready(instance):
|
||||
return blank
|
||||
slug = project["slug"] or project["uid"]
|
||||
profile = editor.resolve(user["uid"], instance)
|
||||
|
||||
@@ -155,8 +155,12 @@ class EditorProfileOut(_Out):
|
||||
class WorkspaceViewOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
owner_uid: str = ""
|
||||
status: str = ""
|
||||
desired_state: str = ""
|
||||
phase: str = ""
|
||||
phase_label: str = ""
|
||||
editor_ready: bool = False
|
||||
suspended: bool = False
|
||||
flag_reason: Optional[str] = ""
|
||||
tunnel_name: Optional[str] = ""
|
||||
|
||||
@@ -162,8 +162,9 @@ Rules: a new hot read-path aggregate follows this exact pattern (module-level `T
|
||||
| `admin.services.{name}` | 5s | `{service}` |
|
||||
| `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) |
|
||||
| `admin.backups` | 8s | `routers/admin/backups._dashboard(can_download=False)` (storage, backups, schedules, metrics) |
|
||||
| `user.{owner_uid}.workspace.{uid}` | 3s | `{workspace, editor_url}` (`provision.view`, the same shape `GET /projects/{slug}/workspace` JSON carries; `None` unless the instance is a workspace owned by `owner_uid`) |
|
||||
|
||||
All these topics are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
All these topics except the last are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. The workspace topic is the one member-facing view: it sits in the owner's private `user.{uid}.*` namespace so the owner (and admins) can subscribe and nobody else can, and the compute callable re-checks `workspace_owner_uid` against the topic so a guessed uid never leaks another member's workspace. `WorkspaceManager` subscribes to it and keeps a 2s/20s HTTP poll as fallback (see `devplacepy/services/containers/CLAUDE.md`). Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
|
||||
**Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls).
|
||||
|
||||
|
||||
@@ -171,6 +171,23 @@ Playwright sessions and finding six terminal tabs. The fallback is now `host-${p
|
||||
extension host, which survives a browser reload and changes when the container restarts, which is
|
||||
exactly the intended semantic.
|
||||
|
||||
**The extension's markers live in a FILE on the state mount, never in a memento (load-bearing).**
|
||||
The six-tab bug was only half fixed by the fallback above: every NEW browser session (a second tab,
|
||||
a reopen from the project page, an incognito window, a Playwright context) makes code-server start
|
||||
another extension host process while the previous one lingers for its 3h reconnection grace, and the
|
||||
new host found no marker, booted again, and the tab list grew by one shell per session - measured on
|
||||
the real image with the shipped extension (one, then two, then three `pravda@workspace` tabs across
|
||||
three sessions, three `Starting extension host process` lines in the container log). Neither
|
||||
`workspaceState` nor `globalState` can carry the marker: in code-server's web workbench both are
|
||||
proxied to the BROWSER's own storage (there is no `state.vscdb` anywhere under the state dir - only
|
||||
per-window `workspaceStorage/<hash>[-N]/vscode.lock` folders), so a memento is per browser profile,
|
||||
and a member's second device or a fresh context has never heard of the boot. The `Memory` class
|
||||
therefore keeps `devplace.bootMarker`, `devplace.panelPreset` and `devplace.welcomeShown` in
|
||||
`{DEVPLACE_WORKSPACE_STATE_DIR}/devplace-extension.json` - the one store whose lifetime is the
|
||||
workspace itself, read fresh on every `get` so concurrent hosts see each other's writes - and falls
|
||||
back to `globalState` only when the env var is absent (a non-DevPlace launch). Verified: three
|
||||
sessions, one shell, one welcome; a new container boot with a changed preset re-applies the layout.
|
||||
|
||||
**Trust is disabled at three layers** and gated by one kill switch, `workspace_editor_trust_all`
|
||||
(default on): the `--disable-workspace-trust` flag, the seeded `security.workspace.trust.*` settings,
|
||||
and the extension's `contributes.configurationDefaults`. The third is belt and braces only -
|
||||
@@ -186,6 +203,28 @@ SQLite database. Writing it from the host is rejected. The extension drives
|
||||
`workbench.action.toggleMaximizedPanel` / `increaseViewSize` instead, so the four presets are named
|
||||
honestly as presets in the UI. Do not "improve" this by writing `state.vscdb`.
|
||||
|
||||
**The resize commands act on the FOCUSED part, and only the `ViewSize` pair does what its name
|
||||
says.** Measured on the real image with Playwright (1000px viewport, fresh state, terminal focused):
|
||||
`increaseViewSize` x4 took the panel from VS Code's default third (333px) to 573px, `decreaseViewSize`
|
||||
x24 drove it to its 77px floor, and `toggleMaximizedPanel` to 943px - while `increaseViewHeight` x4
|
||||
CRUSHED the panel to 93px and `decreaseViewHeight` x24 grew it to 873px. The first live build called
|
||||
`increaseViewSize` right after `terminal.show(true)` (`preserveFocus`), so the focused part was the
|
||||
Welcome editor and every "tall" boot shrank the terminal to its minimum: the production complaint
|
||||
that the `dpc` terminal was unusably small. `Layout.apply(terminal)` therefore takes the boot
|
||||
terminal `BootTerminals.open` returns and focuses it through its own handle
|
||||
(`terminal.show(false)`) - NOT `workbench.action.terminal.focus`, which raced the still-resolving
|
||||
boot terminals and spawned a stray default `bash` as the first tab on a fresh boot - then makes the
|
||||
size deterministic from the one known baseline: `decreaseViewSize` x`PANEL_FLOOR_STEPS` to the floor,
|
||||
then `increaseViewSize` x`PANEL_STEPS[preset]` (short 2, normal 4 = 317px, tall 7; the increment is
|
||||
60px); `maximized` toggles instead. It always normalizes, even on a brand-new state dir where VS
|
||||
Code's own default would already be a third: the panel size is persisted in the member's BROWSER, not
|
||||
in the state dir, so the only way to repair a panel crushed by the old bug on a member's existing
|
||||
browser profile is to normalize unconditionally (verified with one persistent Chromium profile: 93px
|
||||
under the shipped extension, 317px on the next boot under this one). The preset is applied when the
|
||||
recorded `devplace.panelPreset` marker differs from the profile (first boot under this extension, or
|
||||
the member changed it on DevPlace) and never otherwise, so a height the member drags themselves
|
||||
survives every restart.
|
||||
|
||||
**The extension is a built-in, copied to
|
||||
`/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always
|
||||
enabled, cannot be uninstalled, need no install step and survive workspace recreation because they
|
||||
@@ -196,17 +235,37 @@ code-server's Node remote extension host, so `main` applies.
|
||||
|
||||
**`extension.js` is CommonJS, and it is the one file in this repository that may be.** The VS Code
|
||||
extension host loads CommonJS; it is not frontend code and is never served to a browser. Every other
|
||||
house rule applies unchanged. Four small classes (`Profile`, `BootTerminals`, `Layout`, `Presence`)
|
||||
and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a
|
||||
`DevPlace` output channel.
|
||||
house rule applies unchanged. Seven small classes (`Profile`, `Memory`, `BootTerminals`, `Layout`,
|
||||
`Welcome`, `Presence`, `Tunnels`) and an `activate` that runs each through `stage()`, which owns the
|
||||
try/catch and logs to a `DevPlace` output channel.
|
||||
|
||||
**The welcome page is a webview, shown once per state dir.** `Welcome.openOnBoot` opens
|
||||
`media/welcome.html` (placeholders `{{cspSource}}`, `{{nonce}}`, `{{styleUri}}`, `{{iconUri}}`,
|
||||
`{{projectTitle}}`, `{{agentStarted}}`, the two dpc URLs) in a `WebviewPanel` with `preserveFocus`,
|
||||
gated by the `devplace.welcomeShown` global-state memento, so a member sees it on the first boot of
|
||||
a workspace and never again unless they run **DevPlace: Show the welcome page** (or the walkthrough
|
||||
link). Its buttons `postMessage({action})` and the extension maps them through the
|
||||
`WELCOME_COMMANDS` allow-list to commands - a webview never names a command directly. It opens
|
||||
BEFORE `Layout` so the layout's `terminal.focus` leaves the member typing in `dpc`, not reading. The
|
||||
prose is sourced from https://dpc.app.molodetz.nl/ (context size, vision, swarms, deep research,
|
||||
safety gates, daily credits); when dpc's capabilities change, update `welcome.html`, the
|
||||
walkthrough and `/docs/workspace-editor.html` together. `welcome.css` uses only `--vscode-*`
|
||||
variables, which is what keeps it correct in both DevPlace themes.
|
||||
|
||||
**The activation stages are awaited in order, and `Layout` never opens a panel of its own.** Firing
|
||||
them concurrently is what produced a stray third terminal in the first live build: `Layout` called
|
||||
`workbench.action.focusPanel` before `BootTerminals` had created anything, and VS Code answered by
|
||||
spawning its own default `bash`. `Layout.apply(panelIsOpen)` therefore resizes only when the boot
|
||||
terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by
|
||||
spawning its own default `bash`. `Layout.apply(terminal)` therefore resizes only when
|
||||
`BootTerminals.open` actually created a terminal (it returns the one that got focus, `null` when the
|
||||
boot was skipped), and `activate` awaits `terminals`, then `welcome`, then `layout` (presence and
|
||||
the welcome commands are registered first, tunnels last). A skipped boot still calls
|
||||
`BootTerminals.reveal`, which shows the newest existing terminal (or the first one VS Code revives,
|
||||
waited for with `onDidOpenTerminal` up to `REVEAL_TIMEOUT_MS`) and never creates one, so a member
|
||||
opening the same running workspace from a second browser lands on the panel with the terminals that
|
||||
are already there instead of a closed panel or a duplicate agent. Verified by
|
||||
driving a real container with Playwright: the tab list must read exactly
|
||||
`pravda@workspace` + `DevPlace Code`.
|
||||
`pravda@workspace` + `DevPlace Code`, the editor area must hold exactly one `Welcome to DevPlace`
|
||||
tab, and `document.activeElement` must be the terminal's xterm textarea.
|
||||
|
||||
**A workspace suppresses the editor's own AI assistant.** Recent VS Code ships a chat panel in the
|
||||
secondary sidebar, which opened by default with Microsoft branding, "AI responses may be inaccurate"
|
||||
@@ -373,7 +432,11 @@ Every reconciler status mutation flows through `service._set_status(inst, change
|
||||
|
||||
## Testing without Docker
|
||||
|
||||
Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).
|
||||
Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate). Editor readiness in tests is a real listening socket: bind `127.0.0.1:0`, `listen()`, and give the instance `editor_port` 8443 with a `ports_json` mapping that container port to the socket's host port (`tests/e2e/projects/workspace.py` `editor_listener`, `tests/api/projects/workspace.py` `_editor_listener`), so `phase` is exercised against the same probe production uses rather than a mock.
|
||||
|
||||
## Testing against the real image (and the one thing never to do)
|
||||
|
||||
The editor extension, the proxied workbench and the panel layout can only be verified on the real `ppy` image. The harness that works: a scratch code-server container started by hand (`docker run --entrypoint /bin/sh ppy:latest -c 'exec code-server ...'` with the same argv `editor.argv` builds, **no `devplace.instance` label**, the state dir and `/app` bind-mounted from a scratch directory, and the extension directory bind-mounted read-only over `/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace` so an edit is live on the next container start with no `make ppy`), plus a scratch DevPlace server (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` in the scratch dir, `DEVPLACE_DISABLE_SERVICES=1`, another port) seeded with an instance row whose `container_ip` is the scratch container's bridge IP, then Playwright against both. Simulate lifecycle transitions with `store.update_instance` from a script. **Never enable `ContainerService` against a scratch database on the host daemon**: its orphan sweep `rm -f`s every `devplace.instance`-labelled container its database does not know, which on this host is every production workspace. Measure VS Code layout by bounding box, never by assumption - see the `ViewSize`/`ViewHeight` table above for how wrong an assumption was.
|
||||
|
||||
## Vibe coding on-ramp (user-facing doc)
|
||||
|
||||
@@ -447,6 +510,22 @@ while throttled, so a second call would double the request counter). **Request b
|
||||
buffered on purpose**: they are bounded by nginx `client_max_body_size`, and streaming them would
|
||||
force chunked encoding onto arbitrary upstream apps.
|
||||
|
||||
**`<base>` is injected into the ROOT document only (`forward.is_root_document(path)`), never into a
|
||||
nested HTML page.** A base tag exists for one case: the root document requested without a trailing
|
||||
slash (`/p/slug`, `/code`), where relative URLs would otherwise resolve one level too high. Every
|
||||
nested page already resolves its relative URLs against its own directory, so a base tag there is
|
||||
not a no-op but a corruption. The concrete victim was VS Code's webview host page
|
||||
(`.../static/out/vs/workbench/contrib/webview/browser/pre/index.html`, an iframe the Extensions
|
||||
view and every extension detail page load): it registers `service-worker.js` and probes
|
||||
`./fake.html` relative to its own directory, and with the injected base both went to
|
||||
`/code/service-worker.js` and `/code/fake.html` (observed as 404s from the proxy), so the webview's
|
||||
service worker could not register - the "Extensions view crashes" report. code-server's own pages
|
||||
already carry a `<base>` and were never touched, which is exactly why only the internal iframes
|
||||
broke. Reproduce with the real image behind the scratch proxy: fetch the webview `index.html`
|
||||
through `/code/...` and grep for `<base`. The editor route's refusals (`403`/`409`/`502`) carry
|
||||
`media_type="text/plain"`, because a bare `Response` with no content type makes a browser download
|
||||
the error as a file instead of showing it.
|
||||
|
||||
**One keep-alive client, closed on shutdown.** `forward.client()` is a lazily-created module-level
|
||||
`httpx.AsyncClient` with `httpx.Limits` (the `ChromeStealthClient` pattern), closed by
|
||||
`forward.close_client()` in the `main.py` lifespan. A client per request meant a fresh pool and TCP
|
||||
@@ -614,12 +693,44 @@ button in `.project-detail-actions` via `templates/_editor_open.html` (`target="
|
||||
`data-editor-*` attributes `EditorLauncher` reads) straight to the code-server proxy
|
||||
`/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in
|
||||
`routers/projects/index.py` and carried as `workspace_editor_url` on the context and
|
||||
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their
|
||||
workspace for that project exists, is not suspended, and is `store.ST_RUNNING` - the three states the
|
||||
`editor_proxy` route itself refuses (403 suspended, 409 not running, 502 no port), so the button can
|
||||
never open a dead editor. When there is no running workspace the button is absent and the Workspace
|
||||
menu item below is the way in (create/start it there). The workspace page's own **Open editor** link
|
||||
opens in a new tab too; keep both in step.
|
||||
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND
|
||||
`provision.editor_ready(instance)` holds: the workspace exists, is not suspended, is
|
||||
`store.ST_RUNNING`, AND the editor port answers a TCP connect (`api.editor_reachable`, the same
|
||||
`tunnel_target` the proxy dials) - the states the `editor_proxy` route itself refuses (403 suspended,
|
||||
409 not running, 502 no port) plus the boot window in which the container is up but code-server is
|
||||
not yet listening, so the button can never open a dead editor. When there is no ready workspace the
|
||||
button is absent and the Workspace menu item below is the way in (create/start it there). The
|
||||
workspace page's own **Open editor** link opens in a new tab too; keep both in step.
|
||||
|
||||
**The workspace page renders a PHASE, and the phase is computed once, server-side.**
|
||||
`provision.phase(instance, ready)` is a pure function of `suspended_at`, `desired_state`, `status`
|
||||
and the editor probe: `suspended`; `desired=running` -> `ready` when `running` and reachable, else
|
||||
`starting` (covers `created`, a stopped row just asked to start, and the running-but-not-listening
|
||||
boot window); `desired=stopped` -> `stopping` while the container is still `running`/`paused`/
|
||||
`restarting`, `crashed` when the exit was a crash, else `stopped`. `provision.view` adds `phase`,
|
||||
`phase_label` (`PHASE_LABELS`) and `editor_ready` (all on `WorkspaceViewOut`, so Devii's
|
||||
`workspace_status` and the admin console see them too) and reads the probe exactly once per view.
|
||||
The probe is `socket.create_connection` with a 0.3s timeout: a listening or refusing port answers in
|
||||
under a millisecond, so the page only pays the timeout on a dropped leg, which is a case the proxy
|
||||
would 502 on anyway. `workspace.html` renders EVERY phase's controls inside
|
||||
`.workspace-phase-view[data-phase-view="..."]` blocks (space-separated phases, `stopped crashed`
|
||||
share the Start form) and shows one via the `hidden` attribute, so the no-JS page is correct and
|
||||
`WorkspaceManager` only toggles `hidden`, the badge and the launch href - it never rebuilds markup.
|
||||
`starting` shows a disabled `.btn.is-loading` spinner (the `btn-spinner` pattern from
|
||||
`IssueReporter`) plus Stop; `ready` shows the `_editor_open.html` link plus Stop; `stopping` a
|
||||
spinner; `stopped`/`crashed` the Start form (its button carries a spinner span the manager turns on
|
||||
while the POST is in flight, which is what stops a member pressing Start twenty times).
|
||||
`WorkspaceManager` MUST be mounted from the template's inline module
|
||||
(`window.app.workspaceManager = WorkspaceManager.mount()`, the `containers_instance.html` pattern) -
|
||||
it shipped once as a bare `<script type="module" src>` that only defined the class, so the page had
|
||||
no JavaScript at all and the Start button was a plain form post. Liveness is two-fold: an HTTP poll
|
||||
of the page's own JSON at 2s while the phase is transitional and 20s otherwise (`Poller`, swapped
|
||||
when the phase class changes), plus a pub/sub subscription to `user.{owner_uid}.workspace.{uid}`,
|
||||
which the live view relay serves (`_workspace_detail`, 3s, owner-checked; the topic sits in the
|
||||
owner's private namespace so `pubsub/policy.py` admits the owner and admins and nobody else) with
|
||||
the same `{workspace, editor_url}` shape the page JSON carries. Only the start/stop forms
|
||||
(`data-workspace-action`) go through the manager; tunnels, editor preferences and delete keep
|
||||
their native page-reloading submit, which the e2e tests assert with `wait_for_url`.
|
||||
|
||||
**Member entry point** is the project detail page's overflow menu (`project_detail.html`), gated by
|
||||
the `viewer_can_workspace` context flag (`can_open_workspace(project, user)`, set in
|
||||
|
||||
@@ -682,6 +682,14 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def editor_reachable(instance: dict) -> bool:
|
||||
port = int(instance.get("editor_port") or 0)
|
||||
host, target_port = tunnel_target(instance, port)
|
||||
if not host or not target_port:
|
||||
return False
|
||||
return _port_reachable(host, target_port)
|
||||
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const vscode = require("vscode");
|
||||
|
||||
const AGENT_PATH = "/usr/bin/dpc";
|
||||
const AGENT_TERMINAL = "DevPlace Code";
|
||||
const SHELL_TERMINAL = "pravda@workspace";
|
||||
const BOOT_KEY = "devplace.bootMarker";
|
||||
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
|
||||
const PRESET_KEY = "devplace.panelPreset";
|
||||
const WELCOME_KEY = "devplace.welcomeShown";
|
||||
const PANEL_STEPS = { short: 2, normal: 4, tall: 7, maximized: 0 };
|
||||
const PANEL_FLOOR_STEPS = 24;
|
||||
const REVEAL_TIMEOUT_MS = 15000;
|
||||
const PUBLISH_TIMEOUT_MS = 20000;
|
||||
const DPC_SITE_URL = "https://dpc.app.molodetz.nl/";
|
||||
const DPC_DOCS_URL = "https://dpc.app.molodetz.nl/docs";
|
||||
const MEMORY_FILE = "devplace-extension.json";
|
||||
const WELCOME_VIEW_TYPE = "devplace.welcome";
|
||||
const WELCOME_TITLE = "Welcome to DevPlace";
|
||||
const WELCOME_COMMANDS = {
|
||||
agent: "devplace.runAgent",
|
||||
terminal: "workbench.action.terminal.focus",
|
||||
tunnels: "devplace.showTunnels",
|
||||
settings: "devplace.openWorkspacePage",
|
||||
project: "devplace.openProject",
|
||||
docs: "devplace.openDocs",
|
||||
walkthrough: "devplace.openWalkthrough",
|
||||
};
|
||||
|
||||
class Profile {
|
||||
constructor() {
|
||||
@@ -18,7 +38,7 @@ class Profile {
|
||||
{
|
||||
theme: "devplace-dark",
|
||||
layout: "standard",
|
||||
panel_preset: "tall",
|
||||
panel_preset: "normal",
|
||||
boot_agent: "dpc",
|
||||
boot_shell: true,
|
||||
trust_all: true,
|
||||
@@ -66,7 +86,45 @@ class Profile {
|
||||
}
|
||||
|
||||
get panelPreset() {
|
||||
return this.data.panel_preset || "tall";
|
||||
return this.data.panel_preset || "normal";
|
||||
}
|
||||
|
||||
get projectTitle() {
|
||||
return process.env.DEVPLACE_PROJECT_TITLE || "your project";
|
||||
}
|
||||
}
|
||||
|
||||
class Memory {
|
||||
constructor(fallback) {
|
||||
this.fallback = fallback;
|
||||
const directory = process.env.DEVPLACE_WORKSPACE_STATE_DIR || "";
|
||||
this.path = directory ? path.join(directory, MEMORY_FILE) : "";
|
||||
}
|
||||
|
||||
read() {
|
||||
if (!this.path) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.path, "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return error.code === "ENOENT" ? {} : null;
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const stored = this.read();
|
||||
if (stored === null) return this.fallback.get(key);
|
||||
return stored[key];
|
||||
}
|
||||
|
||||
async update(key, value) {
|
||||
const stored = this.read();
|
||||
if (stored === null) {
|
||||
await this.fallback.update(key, value);
|
||||
return;
|
||||
}
|
||||
stored[key] = value;
|
||||
fs.writeFileSync(this.path, JSON.stringify(stored, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,13 +139,36 @@ class BootTerminals {
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (this.alreadyBooted()) return false;
|
||||
if (this.alreadyBooted()) {
|
||||
await this.reveal();
|
||||
return null;
|
||||
}
|
||||
await this.memento.update(BOOT_KEY, this.profile.bootMarker);
|
||||
const shell = this.profile.wantsShell ? this.createShell() : null;
|
||||
const agent = this.profile.wantsAgent ? this.createAgent() : null;
|
||||
if (agent) agent.show(true);
|
||||
else if (shell) shell.show(true);
|
||||
return Boolean(agent || shell);
|
||||
const focused = agent || shell;
|
||||
if (focused) focused.show(true);
|
||||
return focused;
|
||||
}
|
||||
|
||||
async reveal() {
|
||||
const existing = vscode.window.terminals;
|
||||
if (existing.length) {
|
||||
existing[existing.length - 1].show(true);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
listener.dispose();
|
||||
resolve();
|
||||
}, REVEAL_TIMEOUT_MS);
|
||||
const listener = vscode.window.onDidOpenTerminal((terminal) => {
|
||||
clearTimeout(timer);
|
||||
listener.dispose();
|
||||
terminal.show(true);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createAgent() {
|
||||
@@ -112,21 +193,142 @@ class BootTerminals {
|
||||
}
|
||||
|
||||
class Layout {
|
||||
constructor(profile) {
|
||||
constructor(profile, memento) {
|
||||
this.profile = profile;
|
||||
this.memento = memento;
|
||||
}
|
||||
|
||||
async apply(panelIsOpen) {
|
||||
alreadyApplied() {
|
||||
return this.memento.get(PRESET_KEY) === this.profile.panelPreset;
|
||||
}
|
||||
|
||||
async apply(terminal) {
|
||||
if (!terminal || this.alreadyApplied()) return false;
|
||||
const preset = this.profile.panelPreset;
|
||||
if (!panelIsOpen) return;
|
||||
terminal.show(false);
|
||||
if (preset === "maximized") {
|
||||
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel");
|
||||
} else {
|
||||
const steps = PANEL_STEPS[preset] === undefined ? PANEL_STEPS.normal : PANEL_STEPS[preset];
|
||||
await this.resize("workbench.action.decreaseViewSize", PANEL_FLOOR_STEPS);
|
||||
await this.resize("workbench.action.increaseViewSize", steps);
|
||||
}
|
||||
await this.memento.update(PRESET_KEY, preset);
|
||||
return true;
|
||||
}
|
||||
|
||||
async resize(command, steps) {
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
await vscode.commands.executeCommand(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Welcome {
|
||||
constructor(context, profile, output, memory) {
|
||||
this.context = context;
|
||||
this.profile = profile;
|
||||
this.output = output;
|
||||
this.memory = memory;
|
||||
this.panel = null;
|
||||
this.mediaUri = vscode.Uri.joinPath(context.extensionUri, "media");
|
||||
}
|
||||
|
||||
alreadyShown() {
|
||||
return this.memory.get(WELCOME_KEY) === true;
|
||||
}
|
||||
|
||||
register() {
|
||||
this.context.subscriptions.push(
|
||||
vscode.window.registerWebviewPanelSerializer(WELCOME_VIEW_TYPE, {
|
||||
deserializeWebviewPanel: (panel) => this.adopt(panel),
|
||||
}),
|
||||
vscode.commands.registerCommand("devplace.showWelcome", () => this.open(false)),
|
||||
vscode.commands.registerCommand("devplace.openWalkthrough", () =>
|
||||
vscode.commands.executeCommand(
|
||||
"workbench.action.openWalkthrough",
|
||||
"devplace.devplace-workspace#devplace.getStarted",
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async openOnBoot() {
|
||||
if (this.alreadyShown()) return false;
|
||||
await this.memory.update(WELCOME_KEY, true);
|
||||
this.open(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
open(preserveFocus) {
|
||||
if (this.panel) {
|
||||
this.panel.reveal(undefined, preserveFocus);
|
||||
return;
|
||||
}
|
||||
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset];
|
||||
for (let index = 0; index < steps; index += 1) {
|
||||
await vscode.commands.executeCommand("workbench.action.increaseViewSize");
|
||||
}
|
||||
this.adopt(
|
||||
vscode.window.createWebviewPanel(
|
||||
WELCOME_VIEW_TYPE,
|
||||
WELCOME_TITLE,
|
||||
{ viewColumn: vscode.ViewColumn.One, preserveFocus: Boolean(preserveFocus) },
|
||||
{ enableScripts: true, localResourceRoots: [this.mediaUri] },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
adopt(panel) {
|
||||
this.panel = panel;
|
||||
panel.iconPath = vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png");
|
||||
panel.webview.options = { enableScripts: true, localResourceRoots: [this.mediaUri] };
|
||||
panel.webview.html = this.html(panel.webview);
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(message) => this.onMessage(message),
|
||||
null,
|
||||
this.context.subscriptions,
|
||||
);
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
if (this.panel === panel) this.panel = null;
|
||||
},
|
||||
null,
|
||||
this.context.subscriptions,
|
||||
);
|
||||
}
|
||||
|
||||
onMessage(message) {
|
||||
const command = WELCOME_COMMANDS[message && message.action];
|
||||
if (!command) return;
|
||||
vscode.commands.executeCommand(command).then(undefined, (error) =>
|
||||
this.output.appendLine(`welcome: ${command} failed: ${error}`),
|
||||
);
|
||||
}
|
||||
|
||||
html(webview) {
|
||||
const template = fs.readFileSync(
|
||||
path.join(this.mediaUri.fsPath, "welcome.html"),
|
||||
"utf8",
|
||||
);
|
||||
const values = {
|
||||
cspSource: webview.cspSource,
|
||||
nonce: crypto.randomBytes(16).toString("hex"),
|
||||
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "welcome.css")).toString(),
|
||||
iconUri: webview.asWebviewUri(vscode.Uri.joinPath(this.mediaUri, "devplace-icon.png")).toString(),
|
||||
projectTitle: this.profile.projectTitle,
|
||||
dpcSiteUrl: DPC_SITE_URL,
|
||||
dpcDocsUrl: DPC_DOCS_URL,
|
||||
agentStarted: this.profile.wantsAgent ? "already running" : "one command away",
|
||||
};
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
key in values ? this.escape(values[key]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,11 +576,18 @@ async function activate(context) {
|
||||
context.subscriptions.push(output);
|
||||
const profile = new Profile();
|
||||
|
||||
const memory = new Memory(context.globalState);
|
||||
const welcome = new Welcome(context, profile, output, memory);
|
||||
|
||||
await stage(output, "presence", () => new Presence(context).register());
|
||||
const opened = await stage(output, "terminals", () =>
|
||||
new BootTerminals(profile, context.workspaceState).open(),
|
||||
await stage(output, "welcome-commands", () => welcome.register());
|
||||
const focused = await stage(output, "terminals", () =>
|
||||
new BootTerminals(profile, memory).open(),
|
||||
);
|
||||
await stage(output, "welcome", () => welcome.openOnBoot());
|
||||
await stage(output, "layout", () =>
|
||||
new Layout(profile, memory).apply(focused || null),
|
||||
);
|
||||
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
|
||||
await stage(output, "tunnels", () => new Tunnels(output).watch(context));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 40px 48px;
|
||||
}
|
||||
|
||||
.welcome-hero {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 28px;
|
||||
border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
|
||||
}
|
||||
|
||||
.welcome-logo {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.welcome-kicker {
|
||||
margin: 0 0 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.75em;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: 0.92em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.welcome-lead {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
|
||||
}
|
||||
|
||||
.welcome-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
|
||||
border-radius: 8px;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.welcome-card p {
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.welcome-muted {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.welcome-prompts {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.welcome-prompts li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.welcome-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.welcome-button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.welcome-button-primary {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.welcome-button-primary:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.welcome-footer {
|
||||
padding-top: 20px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.welcome {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.welcome-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- retoor <retoor@molodetz.nl> -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src {{cspSource}} https: data:; style-src {{cspSource}}; script-src 'nonce-{{nonce}}';">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{styleUri}}">
|
||||
<title>Welcome to DevPlace</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="welcome">
|
||||
<header class="welcome-hero">
|
||||
<img class="welcome-logo" src="{{iconUri}}" alt="DevPlace" width="64" height="64">
|
||||
<div>
|
||||
<p class="welcome-kicker">DevPlace workspace</p>
|
||||
<h1>Welcome to your workspace for {{projectTitle}}</h1>
|
||||
<p class="welcome-lead">
|
||||
A full editor in your browser, your project files under <code>/app</code>, a login shell,
|
||||
and DevPlace Code, the coding agent that builds with you. It is {{agentStarted}} in the
|
||||
<strong>DevPlace Code</strong> terminal at the bottom of this window.
|
||||
</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-button welcome-button-primary" data-action="terminal">Go to the agent terminal</button>
|
||||
<button class="welcome-button" data-action="walkthrough">Take the tour</button>
|
||||
<a class="welcome-button" href="{{dpcSiteUrl}}">About DevPlace Code</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="welcome-section">
|
||||
<h2>Meet dpc, DevPlace Code</h2>
|
||||
<p>
|
||||
<code>dpc</code> is an autonomous software engineering agent for the terminal. Tell it what you
|
||||
want in plain language; it reads and writes the files in <code>/app</code>, runs commands,
|
||||
installs what it needs, and keeps going until the task is done. It is a single native binary
|
||||
with your DevPlace credentials already configured, so there is nothing to set up.
|
||||
</p>
|
||||
<div class="welcome-grid">
|
||||
<article class="welcome-card">
|
||||
<h3>900k token context</h3>
|
||||
<p>Hold an entire codebase in one session. Context compaction and archival keep long sessions coherent.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Vision</h3>
|
||||
<p>Hand it screenshots, error captures and UI mockups. It designs from a picture and debugs from an error image in the same session as your code.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Agent swarms</h3>
|
||||
<p>Large tasks are split across parallel sub-agents, each with its own context and live output. Builds, tests and lints run side by side.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Deep research</h3>
|
||||
<p>Multi-step web research with cited sources when a task needs facts it does not have yet.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Safe by design</h3>
|
||||
<p>Read-before-write on every edit, verification gates after changes, and session resume so you continue exactly where you stopped.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Your account, your credits</h3>
|
||||
<p>Every token is metered against your own DevPlace account through the platform gateway, with a free daily allowance. Nothing leaves DevPlace.</p>
|
||||
</article>
|
||||
</div>
|
||||
<p class="welcome-muted">
|
||||
More than a hundred tools: file operations, search, shell execution, web retrieval, syntax
|
||||
validation, sub-agent delegation and scheduled prompts. It also reads the same
|
||||
<code>.claude/</code> workflows, agents and slash commands as Claude Code.
|
||||
<a href="{{dpcDocsUrl}}">Read the dpc documentation</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="welcome-section">
|
||||
<h2>Try it now</h2>
|
||||
<p>Click into the <strong>DevPlace Code</strong> terminal and type a request. A few to start with:</p>
|
||||
<ul class="welcome-prompts">
|
||||
<li><code>Explain this project and list what is missing before it can run.</code></li>
|
||||
<li><code>Build a FastAPI app in app.py that serves a JSON health check at /, then start it on port 8000.</code></li>
|
||||
<li><code>Read screenshot.png in /app and implement that layout in index.html.</code></li>
|
||||
<li><code>Run the test suite, fix every failure, and show me the diff.</code></li>
|
||||
</ul>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-button welcome-button-primary" data-action="terminal">Focus the agent terminal</button>
|
||||
<button class="welcome-button" data-action="agent">Start another agent</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="welcome-section">
|
||||
<h2>Inside this workspace</h2>
|
||||
<div class="welcome-grid">
|
||||
<article class="welcome-card">
|
||||
<h3>Your files are your project</h3>
|
||||
<p>Everything under <code>/app</code> syncs back to your DevPlace project, in both directions, including deletions.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Install anything</h3>
|
||||
<p><code>sudo</code> and <code>apt install</code> work with no setup. Python, Rust, Nim and Swift toolchains are preinstalled.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Publish a port</h3>
|
||||
<p>Serve on a high port and forward it in the <strong>Ports</strong> view. DevPlace publishes it on a public HTTPS address for you.</p>
|
||||
</article>
|
||||
<article class="welcome-card">
|
||||
<h3>Every folder is trusted</h3>
|
||||
<p>Nothing opens in Restricted Mode, so tasks and extensions work from the first second.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-button" data-action="tunnels">Show my public tunnels</button>
|
||||
<button class="welcome-button" data-action="settings">Workspace settings</button>
|
||||
<button class="welcome-button" data-action="project">Project on DevPlace</button>
|
||||
<button class="welcome-button" data-action="docs">Editor guide</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="welcome-footer">
|
||||
Reopen this page at any time with <strong>DevPlace: Show the welcome page</strong> from the command palette (F1).
|
||||
</footer>
|
||||
</main>
|
||||
<script nonce="{{nonce}}">
|
||||
(function () {
|
||||
var vscode = acquireVsCodeApi();
|
||||
document.addEventListener("click", function (event) {
|
||||
var trigger = event.target.closest("[data-action]");
|
||||
if (!trigger) return;
|
||||
event.preventDefault();
|
||||
vscode.postMessage({ action: trigger.getAttribute("data-action") });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "devplace-workspace",
|
||||
"displayName": "DevPlace",
|
||||
"description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"publisher": "devplace",
|
||||
"author": "retoor <retoor@molodetz.nl>",
|
||||
"license": "SEE LICENSE IN https://pravda.education/docs/terms.html",
|
||||
@@ -83,12 +83,22 @@
|
||||
"command": "devplace.openDocs",
|
||||
"title": "Open the DevPlace editor guide",
|
||||
"category": "DevPlace"
|
||||
},
|
||||
{
|
||||
"command": "devplace.showWelcome",
|
||||
"title": "Show the welcome page",
|
||||
"category": "DevPlace"
|
||||
},
|
||||
{
|
||||
"command": "devplace.openWalkthrough",
|
||||
"title": "Open the Get started walkthrough",
|
||||
"category": "DevPlace"
|
||||
}
|
||||
],
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "workbench.explorer.emptyView",
|
||||
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)"
|
||||
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)"
|
||||
}
|
||||
],
|
||||
"walkthroughs": [
|
||||
@@ -100,7 +110,7 @@
|
||||
{
|
||||
"id": "agent",
|
||||
"title": "Meet dpc, your coding agent",
|
||||
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)",
|
||||
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)\n[Show the welcome page](command:devplace.showWelcome)",
|
||||
"media": {
|
||||
"markdown": "walkthrough/agent.md"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
**DevPlace Code** terminal at the bottom of this window.
|
||||
|
||||
Ask it for what you want in plain language. It reads and writes the files in `/app`, runs commands,
|
||||
and installs what it needs.
|
||||
and installs what it needs. It holds up to 900k tokens of context, reads screenshots and mockups,
|
||||
splits large tasks across parallel sub-agents, and researches the web with cited sources.
|
||||
|
||||
Every token it spends is metered against your own DevPlace account through the platform AI gateway.
|
||||
Nothing leaves DevPlace.
|
||||
|
||||
Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**.
|
||||
The welcome page that opened on your first boot has example prompts; reopen it with
|
||||
**DevPlace: Show the welcome page**, or read more at [dpc.app.molodetz.nl](https://dpc.app.molodetz.nl/).
|
||||
|
||||
@@ -136,17 +136,19 @@ def upstream_url(scheme: str, authority: str, path: str, query: str) -> str:
|
||||
return url
|
||||
|
||||
|
||||
def is_root_document(path: str) -> bool:
|
||||
return not path.strip("/")
|
||||
|
||||
|
||||
def inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = (
|
||||
lowered.find(b">", head)
|
||||
if head != -1
|
||||
else lowered.find(b">", lowered.find(b"<html"))
|
||||
)
|
||||
opening = lowered.find(b"<head")
|
||||
if opening == -1:
|
||||
opening = lowered.find(b"<html")
|
||||
anchor = lowered.find(b">", opening) if opening != -1 else -1
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
@@ -215,7 +217,12 @@ async def proxy_http(
|
||||
return Response(f"upstream error: {error}", status_code=502)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
headers = response_headers(upstream, prefix)
|
||||
if prefix and rewrite_html and "text/html" in content_type.lower():
|
||||
if (
|
||||
prefix
|
||||
and rewrite_html
|
||||
and is_root_document(path)
|
||||
and "text/html" in content_type.lower()
|
||||
):
|
||||
body = await upstream.aread()
|
||||
await upstream.aclose()
|
||||
content = inject_base(body, prefix)
|
||||
|
||||
@@ -126,7 +126,7 @@ DEFAULTS = {
|
||||
"terminal_font_size": 13,
|
||||
"zoom_level": 0,
|
||||
"layout": "standard",
|
||||
"panel_preset": "tall",
|
||||
"panel_preset": "normal",
|
||||
"boot_agent": "dpc",
|
||||
"boot_shell": True,
|
||||
"window_mode": "tab",
|
||||
|
||||
@@ -18,6 +18,24 @@ CERT_UNCONFIGURED = (
|
||||
"molohttp base URL and credentials before this address serves HTTPS"
|
||||
)
|
||||
|
||||
PHASE_SUSPENDED = "suspended"
|
||||
PHASE_STARTING = "starting"
|
||||
PHASE_READY = "ready"
|
||||
PHASE_STOPPING = "stopping"
|
||||
PHASE_STOPPED = "stopped"
|
||||
PHASE_CRASHED = "crashed"
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_SUSPENDED: "Suspended",
|
||||
PHASE_STARTING: "Starting",
|
||||
PHASE_READY: "Ready",
|
||||
PHASE_STOPPING: "Stopping",
|
||||
PHASE_STOPPED: "Stopped",
|
||||
PHASE_CRASHED: "Crashed",
|
||||
}
|
||||
|
||||
TRANSITIONAL_PHASES = (PHASE_STARTING, PHASE_STOPPING)
|
||||
|
||||
_pending_certificates: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
@@ -223,16 +241,45 @@ def write_manifest(instance: dict) -> None:
|
||||
return
|
||||
|
||||
|
||||
def editor_ready(instance: dict) -> bool:
|
||||
if instance.get("suspended_at"):
|
||||
return False
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return False
|
||||
return api.editor_reachable(instance)
|
||||
|
||||
|
||||
def phase(instance: dict, ready: bool) -> str:
|
||||
if instance.get("suspended_at"):
|
||||
return PHASE_SUSPENDED
|
||||
status = instance.get("status") or ""
|
||||
if instance.get("desired_state") == store.DESIRED_RUNNING:
|
||||
if status == store.ST_RUNNING and ready:
|
||||
return PHASE_READY
|
||||
return PHASE_STARTING
|
||||
if status in (store.ST_RUNNING, store.ST_PAUSED, store.ST_RESTARTING):
|
||||
return PHASE_STOPPING
|
||||
if status == store.ST_CRASHED:
|
||||
return PHASE_CRASHED
|
||||
return PHASE_STOPPED
|
||||
|
||||
|
||||
def view(instance: dict) -> dict:
|
||||
owner_uid = instance.get("workspace_owner_uid", "")
|
||||
limits = quota.resolve(owner_uid, instance)
|
||||
disk_used = int(instance.get("disk_bytes") or 0)
|
||||
egress_used = int(instance.get("egress_bytes") or 0)
|
||||
ready = editor_ready(instance)
|
||||
current = phase(instance, ready)
|
||||
return {
|
||||
"uid": instance.get("uid", ""),
|
||||
"name": instance.get("name", ""),
|
||||
"owner_uid": owner_uid,
|
||||
"status": instance.get("status", ""),
|
||||
"desired_state": instance.get("desired_state", ""),
|
||||
"phase": current,
|
||||
"phase_label": PHASE_LABELS[current],
|
||||
"editor_ready": ready,
|
||||
"suspended": bool(instance.get("suspended_at")),
|
||||
"flag_reason": instance.get("flag_reason", ""),
|
||||
"tunnel_name": instance.get("tunnel_name", ""),
|
||||
|
||||
@@ -139,12 +139,14 @@ class WorkspaceService(BaseService):
|
||||
{"value": "zen", "label": "Zen"}],
|
||||
group="Editor"),
|
||||
ConfigField("workspace_editor_panel_preset", "Terminal panel size",
|
||||
type="select", default="tall",
|
||||
type="select", default="normal",
|
||||
options=[{"value": "short", "label": "Short"},
|
||||
{"value": "normal", "label": "Normal"},
|
||||
{"value": "normal", "label": "Normal (a third of the window)"},
|
||||
{"value": "tall", "label": "Tall"},
|
||||
{"value": "maximized", "label": "Maximized"}],
|
||||
group="Editor"),
|
||||
group="Editor",
|
||||
help="Applied on the first boot of a workspace and again whenever "
|
||||
"the preset changes; a size the member drags themselves is kept."),
|
||||
ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select",
|
||||
default="dpc",
|
||||
options=[{"value": "dpc", "label": "DevPlace Code (dpc)"},
|
||||
|
||||
@@ -44,7 +44,9 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = (
|
||||
read_only=True,
|
||||
summary=(
|
||||
"Read a workspace's state, disk and egress usage against quota, idle "
|
||||
"countdown, tunnels and any open moderation flags."
|
||||
"countdown, tunnels and any open moderation flags. The phase (stopped, "
|
||||
"starting, ready, stopping, crashed, suspended) and editor_ready come "
|
||||
"from a live probe of the editor port, so ready means the editor opens."
|
||||
),
|
||||
params=(SLUG,),
|
||||
),
|
||||
|
||||
@@ -116,6 +116,25 @@ async def _backups(_match: re.Match) -> dict:
|
||||
return _dashboard(can_download=False)
|
||||
|
||||
|
||||
async def _workspace_detail(match: re.Match) -> Optional[dict]:
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import provision
|
||||
|
||||
inst = store.get_instance(match.group("uid"))
|
||||
if not inst or not inst.get("is_workspace"):
|
||||
return None
|
||||
if inst.get("workspace_owner_uid") != match.group("owner"):
|
||||
return None
|
||||
project = _instance_project(inst)
|
||||
slug = project.get("slug") or project.get("uid") or ""
|
||||
return {
|
||||
"workspace": provision.view(inst),
|
||||
"editor_url": (
|
||||
f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
VIEWS = [
|
||||
(re.compile(r"^container\.list$"), _container_list, 4.0),
|
||||
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
|
||||
@@ -126,6 +145,11 @@ VIEWS = [
|
||||
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
|
||||
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
|
||||
(re.compile(r"^admin\.backups$"), _backups, 8.0),
|
||||
(
|
||||
re.compile(rf"^user\.(?P<owner>{_SEGMENT})\.workspace\.(?P<uid>{_SEGMENT})$"),
|
||||
_workspace_detail,
|
||||
3.0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -202,3 +202,40 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.workspace-badge-ready {
|
||||
background: var(--success);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-starting,
|
||||
.workspace-badge-stopping {
|
||||
background: var(--warning);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-crashed,
|
||||
.workspace-badge-suspended {
|
||||
background: var(--danger);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-phase-view {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.workspace-phase-view[hidden],
|
||||
.workspace-phase-hint[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-phase-hint {
|
||||
margin: var(--space-xs) 0 0;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Poller } from "./Poller.js";
|
||||
|
||||
const IDLE_INTERVAL_MS = 20000;
|
||||
const TRANSITION_INTERVAL_MS = 2000;
|
||||
const TRANSITIONAL_PHASES = ["starting", "stopping"];
|
||||
|
||||
export class WorkspaceManager {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.uid = root.dataset.workspaceUid || "";
|
||||
this.ownerUid = root.dataset.ownerUid || "";
|
||||
this.phase = root.dataset.phase || "";
|
||||
this.poller = null;
|
||||
this.interval = 0;
|
||||
this.bind();
|
||||
this.subscribe();
|
||||
this.poll(this.isTransitional(this.phase));
|
||||
}
|
||||
|
||||
static mount() {
|
||||
@@ -19,50 +28,94 @@ export class WorkspaceManager {
|
||||
|
||||
bind() {
|
||||
this.root.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("form");
|
||||
if (!form || form.dataset.confirm || form.dataset.native !== undefined) return;
|
||||
const form = event.target.closest("form[data-workspace-action]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
this.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
async send(form) {
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
this.setBusy(button, true);
|
||||
try {
|
||||
await Http.sendForm(form.action, this.params(form));
|
||||
await Http.sendForm(form.action, this.params(form), { silent: true });
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
window.app?.toast?.show(error.message || "Action failed", { type: "error" });
|
||||
this.toast(error.message || "Action failed", "error");
|
||||
} finally {
|
||||
this.setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
button.classList.toggle("is-loading", busy);
|
||||
}
|
||||
|
||||
params(form) {
|
||||
const params = [];
|
||||
new FormData(form).forEach((value, key) => params.push([key, value]));
|
||||
return params;
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type });
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
const uid = this.root.dataset.workspaceUid;
|
||||
if (uid && window.app?.pubsub) {
|
||||
window.app.pubsub.subscribe(`workspace.${uid}.detail`, () => this.render());
|
||||
}
|
||||
this.poller = new Poller(() => this.refresh(), 20000);
|
||||
this.poller.start();
|
||||
const pubsub = window.app && window.app.pubsub;
|
||||
if (!pubsub || !this.uid || !this.ownerUid) return;
|
||||
pubsub.subscribe(`user.${this.ownerUid}.workspace.${this.uid}`, (data) => this.render(data));
|
||||
}
|
||||
|
||||
isTransitional(phase) {
|
||||
return TRANSITIONAL_PHASES.includes(phase);
|
||||
}
|
||||
|
||||
poll(fast) {
|
||||
const interval = fast ? TRANSITION_INTERVAL_MS : IDLE_INTERVAL_MS;
|
||||
if (this.poller && this.interval === interval) return;
|
||||
if (this.poller) this.poller.stop();
|
||||
this.interval = interval;
|
||||
this.poller = new Poller(() => this.refresh(), interval, {
|
||||
immediate: false,
|
||||
pauseHidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const data = await Http.getJson(`/projects/${this.slug}/workspace`);
|
||||
this.render(data);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
const data = await Http.getJson(`/projects/${this.slug}/workspace`);
|
||||
this.render(data);
|
||||
}
|
||||
|
||||
render(data) {
|
||||
if (!data || !data.workspace) return;
|
||||
const state = this.root.querySelector("[data-workspace-status]");
|
||||
if (state) state.textContent = data.workspace.status || "";
|
||||
const workspace = data.workspace;
|
||||
const badge = this.root.querySelector("[data-workspace-phase]");
|
||||
if (badge) {
|
||||
badge.textContent = workspace.phase_label || workspace.phase || "";
|
||||
badge.className = `workspace-badge workspace-badge-${workspace.phase}`;
|
||||
}
|
||||
const status = this.root.querySelector("[data-workspace-status]");
|
||||
if (status) status.textContent = workspace.status || "";
|
||||
const launch = this.root.querySelector("[data-editor-open]");
|
||||
if (launch && data.editor_url) launch.href = data.editor_url;
|
||||
this.applyPhase(workspace.phase);
|
||||
if (workspace.phase === "ready" && this.phase === "starting") {
|
||||
this.toast("Your workspace is ready", "success");
|
||||
}
|
||||
this.phase = workspace.phase;
|
||||
this.root.dataset.phase = workspace.phase;
|
||||
this.poll(this.isTransitional(workspace.phase));
|
||||
}
|
||||
|
||||
applyPhase(phase) {
|
||||
for (const view of this.root.querySelectorAll("[data-phase-view]")) {
|
||||
const phases = view.dataset.phaseView.split(/\s+/);
|
||||
view.hidden = !phases.includes(phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
|
||||
</form>
|
||||
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
|
||||
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{% if user %}{{ item.votes.up - item.votes.down }}{% endif %}</span>
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<div class="poll-options" role="group" aria-label="Poll options">
|
||||
{% for opt in _poll.options %}
|
||||
<button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>
|
||||
<span class="poll-option-bar" style="--poll-pct: {{ opt.pct }}%;"></span>
|
||||
<span class="poll-option-bar" style="--poll-pct: {% if user %}{{ opt.pct }}{% else %}0{% endif %}%;"></span>
|
||||
<span class="poll-option-label">{{ render_title(opt.label) }}</span>
|
||||
<span class="poll-option-meta">
|
||||
<span class="poll-option-check" aria-hidden="true"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>✓</span>
|
||||
<span class="poll-option-pct">{{ opt.pct }}%</span>
|
||||
<span class="poll-option-pct">{% if user %}{{ opt.pct }}%{% endif %}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="poll-total">{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} · Log in to vote{% endif %}</div>
|
||||
<div class="poll-total">{% if user %}{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% else %}Log in to vote{% endif %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<div class="post-actions">
|
||||
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
|
||||
<span aria-hidden="true">💬</span> {{ item.comment_count }}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{% if user %}{{ item.comment_count }} comments{% else %}Comments{% endif %}">
|
||||
<span aria-hidden="true">💬</span>{% if user %} {{ item.comment_count }}{% endif %}
|
||||
</a>
|
||||
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
|
||||
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>−</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
|
||||
<span class="post-vote-count" data-vote-count="{{ _uid }}">{% if user %}{{ _count }}{% endif %}</span>
|
||||
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button>
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<div class="post-actions">
|
||||
{% set _type = "quiz" %}{% set _uid = item.uid %}{% set _my_vote = item.my_vote %}{% set _count = item.stars %}{% set _btn_class = "post-action-btn" %}{% set _stop = true %}{% include "_star_vote.html" %}
|
||||
<a href="{{ item.url }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
|
||||
<span aria-hidden="true">💬</span> {{ item.comment_count }}
|
||||
<a href="{{ item.url }}" class="post-action-btn" aria-label="{% if user %}{{ item.comment_count }} comments{% else %}Comments{% endif %}">
|
||||
<span aria-hidden="true">💬</span>{% if user %} {{ item.comment_count }}{% endif %}
|
||||
</a>
|
||||
{% set _type = "quiz" %}{% set _uid = item.uid %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
|
||||
{% set _type = "quiz" %}{% set _uid = item.uid %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% for emoji in _emojis %}
|
||||
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}" aria-label="React with {{ emoji }}" aria-pressed="{% if emoji in _mine %}true{% else %}false{% endif %}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{{ guest_disabled(user) }}>
|
||||
<span class="reaction-emoji">{{ emoji }}</span>
|
||||
<span class="reaction-count">{{ _counts.get(emoji, 0) }}</span>
|
||||
<span class="reaction-count">{% if user %}{{ _counts.get(emoji, 0) }}{% endif %}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form method="POST" action="/votes/{{ _type }}/{{ _uid }}" class="inline-form"{% if _stop %} data-stop-propagation{% endif %}>
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{{ _count }}</span></button>
|
||||
<button type="submit" class="{{ _btn_class }} vote-star{% if _my_vote == 1 %} voted{% endif %}" aria-label="Star" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}><span class="vote-count-value" data-vote-count="{{ _uid }}">{% if user %}{{ _count }}{% endif %}</span></button>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,19 @@ it starts a coding agent for you, and it is configured from your DevPlace accoun
|
||||
rather than from inside the editor.
|
||||
|
||||
Open one from a project's **Workspace** page, or with the **Editor** button on the
|
||||
project itself once the workspace is running.
|
||||
project itself once the editor is ready.
|
||||
|
||||
## Starting a workspace
|
||||
|
||||
The workspace page always shows the real state of your workspace: **Stopped**,
|
||||
**Starting**, **Ready**, **Stopping**, **Crashed** or **Suspended**. Press **Start** and the
|
||||
button turns into a spinner reading *Starting the editor* while the container boots and the
|
||||
editor loads. The page watches the workspace for you, so the moment the editor answers the
|
||||
spinner becomes **Open editor** on its own, without a reload. The same rule gates the
|
||||
**Editor** button on the project page: it appears only when the editor will actually open.
|
||||
|
||||
A start usually takes a few seconds. **Stop** stays available throughout, so a workspace that
|
||||
is still starting can always be stopped again.
|
||||
|
||||
## What opens on boot
|
||||
|
||||
@@ -22,12 +34,35 @@ New terminals you open later are plain shells. To start another agent, pick
|
||||
**DevPlace Code** from the terminal dropdown, or run the command
|
||||
**DevPlace: Start DevPlace Code**.
|
||||
|
||||
The workspace opens straight onto your files with the terminal ready, not onto a welcome
|
||||
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant
|
||||
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and
|
||||
`dpc.log`) stay in the container and are never copied into your project.
|
||||
The first time a workspace boots it also opens the **DevPlace welcome page** beside the
|
||||
terminals. It introduces the workspace and DevPlace Code: what `dpc` can do (its 900k token
|
||||
context, vision for screenshots and mockups, parallel sub-agents, deep research with cited
|
||||
sources, and its safety gates), a few prompts to try, and buttons that focus the agent
|
||||
terminal, start the tour, show your public tunnels and open your workspace settings. It is
|
||||
shown once per workspace; reopen it any time with **DevPlace: Show the welcome page**, or
|
||||
read more on the [DevPlace Code site](https://dpc.app.molodetz.nl/).
|
||||
|
||||
You can turn either of them off. See **Your preferences** below.
|
||||
The editor's own built-in chat assistant and VS Code's own welcome page are switched off:
|
||||
`dpc` is the assistant here, and it runs on your DevPlace account. The files `dpc` keeps for
|
||||
itself (`.dpc/` and `dpc.log`) stay in the container and are never copied into your project.
|
||||
|
||||
You can turn either terminal off. See **Your preferences** below.
|
||||
|
||||
## The terminal panel
|
||||
|
||||
`dpc` lives in the terminal panel at the bottom of the window, so its height matters. The
|
||||
**Terminal panel** preference sets it:
|
||||
|
||||
| Preset | Height |
|
||||
|---|---|
|
||||
| Short | A fifth of the window, roughly |
|
||||
| Normal | A third of the window, the default |
|
||||
| Tall | About half the window |
|
||||
| Maximized | The whole editor area |
|
||||
|
||||
The preset is applied the first time a workspace boots and again whenever you change it.
|
||||
Between those moments the height is yours: drag the panel border and DevPlace keeps that
|
||||
size across restarts.
|
||||
|
||||
## Every workspace is trusted
|
||||
|
||||
@@ -65,7 +100,7 @@ The **Editor** card on your workspace page holds them all:
|
||||
- **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
|
||||
inside the editor and DevPlace will not touch it again).
|
||||
- **Layout** - Standard, Terminal focus, or Zen.
|
||||
- **Terminal panel** - Short, Normal, Tall or Maximized.
|
||||
- **Terminal panel** - Short, Normal, Tall or Maximized (see **The terminal panel** above).
|
||||
- **Editor font size**, **Terminal font size**, **Zoom level**.
|
||||
- **Agent on boot** and **Shell on boot**.
|
||||
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the
|
||||
@@ -108,6 +143,8 @@ Press `F1` and type `DevPlace` for the full list:
|
||||
| **DevPlace: Open workspace settings** | Your workspace page |
|
||||
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
|
||||
| **DevPlace: Open the DevPlace editor guide** | This page |
|
||||
| **DevPlace: Show the welcome page** | The welcome page that opened on the first boot |
|
||||
| **DevPlace: Open the Get started walkthrough** | The five-step tour of the workspace |
|
||||
|
||||
## Publishing a port from the editor
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@
|
||||
<span class="issue-author">{{ issue.author_username }}</span>
|
||||
<span class="issue-dot">·</span>
|
||||
<span class="issue-date">{{ local_dt(issue.created_at) }}</span>
|
||||
{% if user %}
|
||||
<span class="issue-dot">·</span>
|
||||
<span class="issue-comments">{{ issue.comments_count }} comment{{ '' if issue.comments_count == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -210,8 +210,6 @@
|
||||
{% endif %}
|
||||
<div class="landing-post-content rendered-content">{{ render_content(item.post['content'][:200] ~ ('...' if item.post['content']|length > 200 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
||||
<div class="landing-post-footer">
|
||||
<span class="landing-post-stat">+{{ item.stars }}</span>
|
||||
<span class="landing-post-stat">💬 {{ item.comment_count }}</span>
|
||||
<a href="/posts/{{ item.slug }}" class="landing-post-open">Open →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -603,7 +603,9 @@
|
||||
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card">
|
||||
<div class="gist-card-header">
|
||||
<h3 class="gist-card-title">{{ render_title(g['title'], author_is_admin=is_admin(profile_user)) }}</h3>
|
||||
{% if user %}
|
||||
<span class="gist-card-star">☆ {{ g.get('stars', 0) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="gist-card-meta">
|
||||
<span class="gist-language-badge">📝 {{ language_name(g['language']) }}</span>
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
{% if gallery %}
|
||||
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a>
|
||||
{% endif %}
|
||||
<a href="#comments" class="project-tab">Comments <span class="project-tab-count">{{ comment_count }}</span></a>
|
||||
<a href="#comments" class="project-tab">Comments{% if user %} <span class="project-tab-count">{{ comment_count }}</span>{% endif %}</a>
|
||||
<a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a>
|
||||
</nav>
|
||||
|
||||
@@ -214,9 +214,13 @@
|
||||
<div class="project-sidebar-card">
|
||||
<h2 class="project-section-label">Stats</h2>
|
||||
<div class="project-stats">
|
||||
{% if user %}
|
||||
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
|
||||
{% endif %}
|
||||
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
|
||||
{% if user %}
|
||||
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
|
||||
{% endif %}
|
||||
<span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
|
||||
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<div class="workspace-page" data-workspace-root
|
||||
data-slug="{{ project.slug or project.uid }}"
|
||||
data-workspace-uid="{{ workspace.uid if has_workspace else '' }}"
|
||||
data-owner-uid="{{ workspace.owner_uid if has_workspace else '' }}"
|
||||
data-phase="{{ workspace.phase if has_workspace else '' }}"
|
||||
data-has-workspace="{{ 1 if has_workspace else 0 }}">
|
||||
|
||||
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
|
||||
@@ -45,7 +47,10 @@
|
||||
{% endfor %}
|
||||
|
||||
<div class="card workspace-summary">
|
||||
<div class="workspace-state" data-workspace-status>{{ workspace.status }}</div>
|
||||
<div class="workspace-state">
|
||||
<span class="workspace-badge workspace-badge-{{ workspace.phase }}" data-workspace-phase>{{ workspace.phase_label }}</span>
|
||||
<span class="workspace-muted" data-workspace-status>{{ workspace.status }}</span>
|
||||
</div>
|
||||
<div class="workspace-meters">
|
||||
<div class="workspace-meter">
|
||||
<span>Disk</span>
|
||||
@@ -69,24 +74,34 @@
|
||||
Last active {{ dt_ago(workspace.last_active_at) }}.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="workspace-actions">
|
||||
{% if workspace.status == "running" %}
|
||||
{% set _url = editor_url %}
|
||||
{% set _uid = workspace.uid %}
|
||||
{% set _class = "btn btn-primary" %}
|
||||
{% set _mode = editor.window_mode %}
|
||||
{% set _width = editor.window_width %}
|
||||
{% set _height = editor.window_height %}
|
||||
{% set _label = "Open editor" %}
|
||||
{% include "_editor_open.html" %}
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn">Stop</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-primary">Start</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="workspace-actions" data-workspace-actions>
|
||||
<div class="workspace-phase-view" data-phase-view="ready" {{ "hidden" if workspace.phase != "ready" }}>
|
||||
{% set _url = editor_url %}
|
||||
{% set _uid = workspace.uid %}
|
||||
{% set _class = "btn btn-primary" %}
|
||||
{% set _mode = editor.window_mode %}
|
||||
{% set _width = editor.window_width %}
|
||||
{% set _height = editor.window_height %}
|
||||
{% set _label = "Open editor" %}
|
||||
{% include "_editor_open.html" %}
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn">Stop</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="workspace-phase-view" data-phase-view="starting" {{ "hidden" if workspace.phase != "starting" }}>
|
||||
<button type="button" class="btn btn-primary is-loading" disabled><span class="btn-spinner" aria-hidden="true"></span>Starting the editor</button>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn">Stop</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="workspace-phase-view" data-phase-view="stopping" {{ "hidden" if workspace.phase != "stopping" }}>
|
||||
<button type="button" class="btn is-loading" disabled><span class="btn-spinner" aria-hidden="true"></span>Stopping</button>
|
||||
</div>
|
||||
<div class="workspace-phase-view" data-phase-view="stopped crashed" {{ "hidden" if workspace.phase not in ("stopped", "crashed") }}>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-primary"><span class="btn-spinner" aria-hidden="true"></span>Start</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if editor_password %}
|
||||
<code class="secret-value" id="editor-password">{{ editor_password }}</code>
|
||||
<button type="button" class="btn btn-sm" data-copy="editor-password">Copy password</button>
|
||||
@@ -95,6 +110,9 @@
|
||||
<button type="submit" data-confirm="Delete this workspace?" data-confirm-danger class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="workspace-muted workspace-phase-hint" data-phase-view="starting" {{ "hidden" if workspace.phase != "starting" }}>
|
||||
The container is booting and the editor is loading. This usually takes a few seconds; the button above changes on its own once the editor answers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card workspace-editor" data-editor-card>
|
||||
@@ -108,11 +126,11 @@
|
||||
{% if restart_required %}
|
||||
<div class="workspace-editor-restart">
|
||||
<span>Your editor settings changed. Restart the workspace to apply them.</span>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn btn-sm">Stop</button>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace/stop">
|
||||
<button type="submit" class="btn btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Stop</button>
|
||||
</form>
|
||||
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Start</button>
|
||||
<form method="post" data-workspace-action action="/projects/{{ project.slug or project.uid }}/workspace">
|
||||
<button type="submit" class="btn btn-sm btn-primary"><span class="btn-spinner" aria-hidden="true"></span>Start</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -272,5 +290,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="module" src="{{ static_url('/static/js/WorkspaceManager.js') }}"></script>
|
||||
<script type="module">
|
||||
import { WorkspaceManager } from "{{ static_url('/static/js/WorkspaceManager.js') }}";
|
||||
window.app.workspaceManager = WorkspaceManager.mount();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user