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
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# 023 — WP6c: the stack, the scope split, and switching presets in place

Three asks, one panel:

1. "각 위치를 순서대로 쌓는거" — assembly order should be VISIBLE, not inferred
from row order.
2. "live 같은 live에만 주입되는건 별도로" — layers that only appear in a live
turn should not sit in the same list as layers that ship every turn.
3. "팝업에서 추가해서 바로바로 ... 개인 프리셋 갈아끼우면서" — move between
saved presets inside the dialog, editing in place.

## What "live only" actually means

Measured, not assumed. Three layers render on a TRANSITION rather than on every
turn, and they are not the same kind of conditional:

| Layer | When it renders | Source |
|---|---|---|
| realtime | only as a session enters or leaves realtime | `realtime.rs:43-53`, `:77-90` |
| model-switch | only after the model changed mid-conversation | `model_switch_instructions.rs:40` |
| agents-md | when the project doc CHANGES, not on every turn | `agents_md.rs:52-64` |

The first two are genuinely live-scoped: a user reading a steady-state prompt
will never see them. `agents-md` is different — it is diff-rendered like every
other section, so grouping it with realtime would be wrong.

So the split is **two groups, not a filter**: layers that ship in a steady-state
turn, and layers that only appear on a transition. Both stay visible; the second
group carries the condition that produces it.

## Ordering

`LAYER_INVENTORY` already carries `order`, and the panel already sorts by it. What
it does not do is SHOW it, so "this is a stack" is something the reader has to
infer from vertical position alone.

The research lane (Figma, Photoshop, Zapier, Atlassian, W3C APG) converges on the
same three things for an ordered list: a visible position, a persistent affordance
when the order is editable, and a keyboard path that is not the drag handle.

Two important limits here:

- **Built-in order is not editable.** It comes from `world_state.rs`, so a drag
handle on those rows would promise something the runtime does not honor. They
get a position number and nothing else.
- **Custom layers ARE ordered by the user** and already have up/down buttons from
WP5. They gain the same position number so both halves read as one stack.

## Preset switching inside the dialog

The ask names swipe or `<-->`. The search lane found no shipped product doing
arrow-cycling for presets: VS Code Profiles, Arc Boosts, and Cursor Rules all use
an explicit current-item label plus a list, and edit in place. The one consistent
warning is that arrows WITHOUT a position indicator lose the user.

So: prev/next controls as asked, plus `n / total` so the position is never
ambiguous, plus the name of the current preset. Editing stays in the same dialog.

This is the WP5 custom-layer editor gaining navigation, not a new surface. A
preset opened this way is still an ordinary custom layer — same endpoint, same
validation, same revision handling.

## Files

```
gui/src/pages/codex-set-prompt.tsx (group the stack, pass positions)
gui/src/components/codex-set/PromptLayerRow.tsx (position number)
gui/src/components/codex-set/CustomLayerRow.tsx (position number)
gui/src/components/codex-set/CustomLayerDialog.tsx (prev/next + position)
gui/src/styles-codex-set.css (stack rail, group heading)
```

## Tests

1. every row shows its position, and the numbers ascend without gaps
2. the live-only group contains exactly realtime and model-switch
3. agents-md stays in the steady-state group — it is diff-rendered, not live-only
4. next/prev move between custom layers and the position indicator follows
5. the indicator is present whenever the controls are (the lost-user guard)
6. editing a layer reached by navigation writes through the ordinary custom path
7. navigation is disabled at the ends rather than wrapping silently
8. a dirty editor does not lose its text when the user navigates away

Case 8 is the one that matters: navigation inside an editor is a new way to
discard someone's typing, and WP5 already had to fix that once for Cancel.

## Round 2 corrections

An audit rejected three things above. All three are retractions, not defences.

### "Steady-state" and "every turn" are both false

I wrote that one group "ships every turn". Almost nothing does: world-state
sections are diff-rendered, so an unchanged section sends nothing on an ordinary
turn. Naming the groups that way would have taught the user the same wrong model
that produced the last two rounds of corrections.

The axis is not steady-state versus live. It is what the layer IS:

- **State layers** — describe configuration or context, and render when their
snapshot first appears or changes.
- **Transition notices** — exist only to announce a change. `realtime`
(`realtime.rs:43-53`) and `model-switch` (`model.rs:44-60`) have no steady
state to describe; a notice about a change that did not happen is nothing.

That is the honest version of "live에만 주입되는 것": exactly two layers, and
the reason is their kind rather than a scope flag.

### One position sequence would misrepresent the runtime

Two separate errors in the original numbering plan.

First, renumbering per visual group. If the transition notices are lifted out,
the remaining rows must keep their ORIGINAL assembly indices, gaps included.
Renumbering 1..n inside each group would invent an order the runtime does not
have. "Numbers ascend without gaps" is withdrawn as a test.

Second, and worse: custom layers do not interleave with built-ins at all. They
concatenate into ONE `developer_instructions` projection
(`prompt-layers.ts:384-386`), which occupies a single slot. Numbering built-ins
and custom layers in one sequence would draw a fifteen-plus-n stack that does
not exist.

So: built-ins carry their assembly index. Custom layers are an ordered sub-stack
INSIDE their one slot, numbered among themselves, and the panel says so.

### Navigation changes the editor target and nothing else

Presets are saved custom layers, and they are not mutually exclusive — every
enabled one composes into the projection together. Prev/next must therefore move
only which layer is being EDITED. It must not toggle enablement, and the copy
must not imply a single active preset.

### Evidence wording

"Measured, not assumed" was wrong for this section: the classification comes from
reading `render_diff`, not from captured output. It is source-verified, and the
two-member transition group gets a fixture-backed test before it becomes UI
taxonomy.

## Tests, restated

1. every built-in row shows its CANONICAL `order` from `LAYER_INVENTORY`, gaps
included after grouping — not a renumbered 1..n
2. the transition group contains exactly `realtime` and `model-switch`
3. every built-in appears exactly once across the two groups, and none is dropped
4. custom layers are numbered among THEMSELVES, and the panel states they share
one slot rather than interleaving with built-ins
5. next/prev move the editor target; title and body follow the layer, and
navigating writes nothing
6. the position indicator exists whenever the controls do
7. at the ends the controls are disabled, and clicking one leaves the editor
unchanged rather than wrapping
8. **edit A, navigate to B, come back to A: the unsaved title and body are still
there, and no PUT was issued before Save.** Blocking navigation while dirty
would pass a weaker version of this test while making the feature useless
9. saving a layer reached by navigation goes to `/api/codex-prompt/custom` with a
stable id, the siblings intact, and the current revision
10. zero and one custom layers: the controls are absent or consistently disabled
11. a refresh that deletes the layer under an open editor does not strand it

24 changes: 23 additions & 1 deletion gui/src/components/codex-set/CustomLayerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,29 @@ export default function CustomLayerRow({
}) {
const t = useT();
return (
<li className="codex-set-prompt__row codex-set-custom__row" data-custom-id={layer.id}>
<li
className="codex-set-prompt__row codex-set-custom__row"
data-custom-id={layer.id}
// Alt+Arrow reorders from anywhere in the row, per the W3C rearrangeable
// listbox pattern. The up/down buttons stay: a shortcut nobody discovers is
// not an affordance, and a button is what makes the capability visible.
onKeyDown={event => {
if (!event.altKey || busy) return;
if (event.key === "ArrowUp" && index > 0) {
event.preventDefault();
onMove(layer.id, -1);
} else if (event.key === "ArrowDown" && index < total - 1) {
event.preventDefault();
onMove(layer.id, 1);
}
}}
>
{/*
Numbered among themselves, not continuing the built-in sequence. Custom
layers concatenate into ONE developer_instructions section, so sharing a
sequence with the built-ins would draw a stack that does not exist.
*/}
<span className="codex-set-prompt__pos" aria-hidden="true">{index + 1}</span>
<button type="button" className="link-btn codex-set-prompt__name" onClick={() => onEdit(layer.id)}>
{layer.title}
</button>
Expand Down
50 changes: 49 additions & 1 deletion gui/src/components/codex-set/PromptLayerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ import { CLASS_LABEL_KEYS, LAYER_ABOUT_KEYS, LAYER_CONDITION_KEYS, LAYER_LABEL_K
export default function PromptLayerDialog({
descriptor,
toggle,
text,
busy,
onToggle,
onClose,
}: {
descriptor: LayerDescriptorDto;
toggle: ToggleStateDto | undefined;
/** Rendered source text for this layer, when the probe could read it. */
text: { text: string | null; reason: string; bytes: number; sourcePath?: string } | undefined;
busy: boolean;
onToggle: (id: string, enabled: boolean) => void;
onClose: () => void;
}) {
const t = useT();
Expand Down Expand Up @@ -62,6 +69,24 @@ export default function PromptLayerDialog({
<div className="modal-card codex-set-layer-dialog" onClick={event => event.stopPropagation()} role="document">
<div className="modal-head">
<h3 id={titleId}>{labelKey ? t(labelKey) : descriptor.id}</h3>
{/*
The switch belongs here too. Opening a layer to read it and then having
to close the dialog to act on it is a dead end, and the row and the
dialog are the same control over the same key.
*/}
{descriptor.class === "config-toggle" && toggle && (
<button
type="button"
role="switch"
className={`toggle ${toggle.defaultedUserValue ? "on" : ""}`}
aria-checked={toggle.defaultedUserValue}
aria-label={labelKey ? t(labelKey) : descriptor.id}
disabled={busy}
onClick={() => { onToggle(descriptor.id, !toggle.defaultedUserValue); }}
>
<span className="toggle-knob" />
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>{t("common.close")}</button>
</div>

Expand Down Expand Up @@ -110,7 +135,30 @@ export default function PromptLayerDialog({
Stated, not implied. A dialog that simply omitted the body would read as
a loading failure to anyone who expected one.
*/}
<p className="muted small codex-set-layer-dialog__no-text">{t("codexSet.dialog.noRenderedText")}</p>
{text?.reason === "ok" && text.text ? (
<>
<div className="codex-set-layer-dialog__line">
<span className="muted text-label">{t("codexSet.dialog.sourceText")}</span>
<span className="muted small">{t("codexSet.dialog.sourceBytes", { bytes: text.bytes })}</span>
</div>
<pre className="api-code codex-set-layer-dialog__text">{text.text}</pre>
</>
) : (
// Each absent case has a different cause, and saying which one is the
// difference between a limit and a bug.
<p className="muted small codex-set-layer-dialog__no-text">
{text?.reason === "empty-source"
// An existing but empty file is a fourth state. Reporting it as
// "sent nothing" would describe the layer as idle when the real
// answer is that the file the user wrote is blank.
? t("codexSet.dialog.emptySource", { path: text.sourcePath ?? "" })
: text?.reason === "not-rendered"
? t("codexSet.dialog.notRendered")
: text?.reason === "not-exposed"
? t("codexSet.dialog.notExposed")
: t("codexSet.dialog.textUnavailable")}
</p>
)}
</div>
</dialog>
);
Expand Down
33 changes: 32 additions & 1 deletion gui/src/components/codex-set/PromptLayerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@ import { LAYER_LABEL_KEYS } from "./prompt-layer-copy";
export default function PromptLayerRow({
descriptor,
toggle,
bytes,
transitionOnly = false,
busy,
writesRefused,
onToggle,
onOpen,
}: {
descriptor: LayerDescriptorDto;
toggle: ToggleStateDto | undefined;
/** Measured size of what this layer actually sent, when known. */
bytes: number | null;
/** A notice about a change has no steady state to be "on" in. */
transitionOnly?: boolean;
busy: boolean;
writesRefused: boolean;
onToggle: (id: string, enabled: boolean) => void;
Expand All @@ -45,6 +51,15 @@ export default function PromptLayerRow({

return (
<li className="codex-set-prompt__row" data-layer-id={descriptor.id} data-layer-class={descriptor.class}>
{/*
The CANONICAL assembly index, gaps included. Renumbering per visual group
would invent an order the runtime does not have: these positions come from
world_state.rs and are not user-reorderable, which is also why there is no
drag handle here.
*/}
<span className="codex-set-prompt__pos" aria-hidden="true">
{descriptor.order === null ? "\u00b7" : descriptor.order + 1}
</span>
<button
type="button"
className="link-btn codex-set-prompt__name"
Expand All @@ -55,6 +70,17 @@ export default function PromptLayerRow({

{descriptor.key && <code className="codex-set-prompt__key">{descriptor.key}</code>}

{/*
Weight, shown where the decision happens. This is a prompt-budget page: a
layer that costs 15 KB and one that costs 300 bytes should not look
identical while the user decides which to keep.
*/}
{bytes !== null && bytes > 0 && (
<span className="codex-set-prompt__bytes" title={t("codexSet.dialog.sourceBytes", { bytes })}>
{bytes >= 1024 ? Math.round(bytes / 1024) + " KB" : bytes + " B"}
</span>
)}

{descriptor.class === "config-toggle" ? (
// The dashboard's switch is a button with a knob, not a checkbox. A raw
// <input type="checkbox"> renders as an actual checkbox here because the
Expand Down Expand Up @@ -93,7 +119,12 @@ export default function PromptLayerRow({
) : (
// base and runtime-conditional: no off-switch exists anywhere in Codex.
<span className="codex-set-prompt__note codex-set-prompt__note--locked">
{t("codexSet.row.alwaysOn")}
{/*
"Always on" is false for a transition notice: it is not on, it fires.
Reusing the locked label would tell the user this text is in every
prompt when it appears only at a change.
*/}
{transitionOnly ? t("codexSet.row.onChange") : t("codexSet.row.alwaysOn")}
</span>
)}
</li>
Expand Down
Loading
Loading