@parley-im/parley 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +20 -11
- package/dist/agents/codex.js +10 -5
- package/dist/agents/process.js +35 -4
- package/dist/api.js +32 -10
- package/dist/autostart.js +86 -0
- package/dist/charter.js +6 -5
- package/dist/cli.js +62 -16
- package/dist/config.js +49 -9
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +111 -27
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +35 -10
- package/dist/runner.js +9 -7
- package/dist/seal.js +7 -5
- package/dist/server.js +43 -17
- package/dist/state.js +7 -4
- package/dist/transcript.js +10 -6
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +29 -13
- package/web/chat.html +6 -6
- package/web/chat.js +198 -62
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
package/dist/server.js
CHANGED
|
@@ -4,13 +4,14 @@ import { join, extname, dirname } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
+
import { VERSION } from "./version.js";
|
|
7
8
|
import { Store, sessionDir } from "./db.js";
|
|
8
9
|
import { Runner, mediaTypeFor } from "./runner.js";
|
|
9
10
|
import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
|
|
10
11
|
import { RelayLink } from "./relay.js";
|
|
11
12
|
import { createSession } from "./control.js";
|
|
12
13
|
import { readCredentials, relay as registry, ownerName } from "./account.js";
|
|
13
|
-
import { writeState, clearState } from "./state.js";
|
|
14
|
+
import { writeState, clearState, isServerUp } from "./state.js";
|
|
14
15
|
const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
|
|
15
16
|
const MAX_UPLOAD = 10 * 1024 * 1024;
|
|
16
17
|
// Long polls end well before any proxy's idle timeout.
|
|
@@ -22,7 +23,7 @@ const STATIC_TYPES = {
|
|
|
22
23
|
".html": "text/html; charset=utf-8",
|
|
23
24
|
};
|
|
24
25
|
// Two local servers: your control panel (and your view of each session), and
|
|
25
|
-
// local
|
|
26
|
+
// local guest links for development or when you're not signed in. Shared
|
|
26
27
|
// links go through parley.im instead, over the connection in relay.ts.
|
|
27
28
|
export async function serve() {
|
|
28
29
|
const config = loadConfig();
|
|
@@ -36,12 +37,12 @@ export async function serve() {
|
|
|
36
37
|
dashboardPort: config.dashboardPort,
|
|
37
38
|
startedAt: new Date().toISOString(),
|
|
38
39
|
};
|
|
39
|
-
const
|
|
40
|
+
const guest = createServer((req, res) => handleGuest(req, res).catch((e) => fail(res, e)));
|
|
40
41
|
const dashboard = createServer((req, res) => handleDashboard(req, res).catch((e) => fail(res, e)));
|
|
41
|
-
async function
|
|
42
|
+
async function handleGuest(req, res) {
|
|
42
43
|
const url = new URL(req.url ?? "/", "http://x");
|
|
43
44
|
if (url.pathname === "/health")
|
|
44
|
-
return json(res, { ok: true, pid: process.pid });
|
|
45
|
+
return json(res, { ok: true, pid: process.pid, version: VERSION });
|
|
45
46
|
if (url.pathname.startsWith("/static/"))
|
|
46
47
|
return sendStatic(res, url.pathname.slice(8));
|
|
47
48
|
const m = url.pathname.match(/^\/s\/([A-Za-z0-9_-]{16,})(\/.*)?$/);
|
|
@@ -68,7 +69,7 @@ export async function serve() {
|
|
|
68
69
|
}
|
|
69
70
|
const url = new URL(req.url ?? "/", "http://x");
|
|
70
71
|
if (url.pathname === "/health")
|
|
71
|
-
return json(res, { ok: true, pid: process.pid });
|
|
72
|
+
return json(res, { ok: true, pid: process.pid, version: VERSION });
|
|
72
73
|
if (url.pathname === "/")
|
|
73
74
|
return sendStatic(res, "panel.html");
|
|
74
75
|
if (url.pathname.startsWith("/static/"))
|
|
@@ -91,19 +92,19 @@ export async function serve() {
|
|
|
91
92
|
try {
|
|
92
93
|
session = createSession(store, config, {
|
|
93
94
|
projectDir: String(body.projectDir ?? ""),
|
|
94
|
-
|
|
95
|
+
guest: String(body.guest ?? ""),
|
|
95
96
|
brief: String(body.brief ?? ""),
|
|
96
97
|
greeting: body.greeting ? String(body.greeting) : null,
|
|
97
|
-
agent:
|
|
98
|
+
agent: body.agent === "codex" || body.agent === "claude" ? body.agent : null,
|
|
98
99
|
model: body.model ? String(body.model) : null,
|
|
99
|
-
replyLimit: body.replyLimit ? Number(body.replyLimit)
|
|
100
|
+
replyLimit: body.replyLimit == null ? null : Number(body.replyLimit),
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
catch (error) {
|
|
103
104
|
return problem(res, 400, "invalid", error.message);
|
|
104
105
|
}
|
|
105
106
|
const shared = await share(session);
|
|
106
|
-
return json(res, { ...api.ownerView(session), shared });
|
|
107
|
+
return json(res, { ...api.ownerView(session), session, shared });
|
|
107
108
|
}
|
|
108
109
|
const action = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)\/(end|delete|limit)$/);
|
|
109
110
|
if (action && req.method === "POST") {
|
|
@@ -170,8 +171,22 @@ export async function serve() {
|
|
|
170
171
|
const body = await readJson(req, MAX_TEXT * 4 + 10_000);
|
|
171
172
|
const textBody = String(body.text ?? "").trim();
|
|
172
173
|
const images = (Array.isArray(body.images) ? body.images : []).map(String).filter((name) => ownsUpload(session, name));
|
|
174
|
+
const clientId = typeof body.cid === "string" ? body.cid.slice(0, 64) : null;
|
|
175
|
+
const again = api.resent(scope, clientId);
|
|
176
|
+
if (again)
|
|
177
|
+
return json(res, again);
|
|
173
178
|
api.check(scope, textBody, images.length);
|
|
174
|
-
return json(res, api.post(scope, textBody, images));
|
|
179
|
+
return json(res, api.post(scope, textBody, images, clientId));
|
|
180
|
+
}
|
|
181
|
+
// The guest ends it. Local links only; on parley.im it's a sealed request.
|
|
182
|
+
if (rest === "/api/end" && req.method === "POST") {
|
|
183
|
+
if (scope.owner)
|
|
184
|
+
throw new Problem(403, "read_only", "Your view is read-only.");
|
|
185
|
+
api.endByGuest(scope);
|
|
186
|
+
const creds = readCredentials(config);
|
|
187
|
+
if (creds)
|
|
188
|
+
await registry.end(creds, session).catch(() => { });
|
|
189
|
+
return json(res, { ok: true });
|
|
175
190
|
}
|
|
176
191
|
if (rest === "/api/uploads" && req.method === "POST") {
|
|
177
192
|
if (scope.owner)
|
|
@@ -195,7 +210,9 @@ export async function serve() {
|
|
|
195
210
|
if (!ownsUpload(session, name) || !existsSync(file))
|
|
196
211
|
return notFound(res);
|
|
197
212
|
res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
|
|
198
|
-
return createReadStream(file)
|
|
213
|
+
return createReadStream(file)
|
|
214
|
+
.on("error", () => res.destroy())
|
|
215
|
+
.pipe(res);
|
|
199
216
|
}
|
|
200
217
|
return notFound(res);
|
|
201
218
|
}
|
|
@@ -224,9 +241,15 @@ export async function serve() {
|
|
|
224
241
|
events: events.map(({ event }) => api.eventView(event, scope)).filter(Boolean),
|
|
225
242
|
});
|
|
226
243
|
}
|
|
227
|
-
|
|
244
|
+
// Already running (started at login, and again by hand, say): leave it be
|
|
245
|
+
// and exit cleanly, which also tells a login service not to retry.
|
|
246
|
+
if (await isServerUp(config)) {
|
|
247
|
+
console.log(`parley: a server is already running on ${config.dashboardPort}; leaving it be.`);
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
|
250
|
+
guest.listen(config.port, "127.0.0.1");
|
|
228
251
|
dashboard.listen(config.dashboardPort, "127.0.0.1");
|
|
229
|
-
await Promise.all([
|
|
252
|
+
await Promise.all([guest, dashboard].map((server) => new Promise((resolve, reject) => {
|
|
230
253
|
server.once("listening", resolve);
|
|
231
254
|
server.once("error", reject);
|
|
232
255
|
})));
|
|
@@ -234,17 +257,20 @@ export async function serve() {
|
|
|
234
257
|
console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
|
|
235
258
|
link.start();
|
|
236
259
|
runner.resume();
|
|
260
|
+
// Says goodbye to parley.im before exiting, so guests see this computer go
|
|
261
|
+
// offline at once rather than when parley.im notices the silence.
|
|
237
262
|
const shutdown = () => {
|
|
238
263
|
link.stop();
|
|
239
264
|
runner.stopAll();
|
|
240
265
|
clearState(process.pid);
|
|
241
|
-
process.exit(0);
|
|
266
|
+
setTimeout(() => process.exit(0), 300);
|
|
242
267
|
};
|
|
243
268
|
process.on("SIGINT", shutdown);
|
|
244
269
|
process.on("SIGTERM", shutdown);
|
|
245
|
-
// One bad turn (say, a session deleted mid-reply)
|
|
246
|
-
// other
|
|
270
|
+
// One bad turn (say, a session deleted mid-reply) or one broken request
|
|
271
|
+
// shouldn't take every other guest's link down with it.
|
|
247
272
|
process.on("unhandledRejection", (error) => console.error("unhandled:", error));
|
|
273
|
+
process.on("uncaughtException", (error) => console.error("uncaught:", error));
|
|
248
274
|
}
|
|
249
275
|
function sendFile(res, status, name) {
|
|
250
276
|
send(res, status, STATIC_TYPES[extname(name)], readFileSync(join(WEB, name)));
|
package/dist/state.js
CHANGED
|
@@ -26,16 +26,19 @@ export function linkFor(session, config) {
|
|
|
26
26
|
return `${config.relayUrl}/s/${session.token}#${session.e2e_key}`;
|
|
27
27
|
return `http://localhost:${config.port}/s/${session.token}`;
|
|
28
28
|
}
|
|
29
|
-
export const isShared = (config) => readCredentials(config) !== null;
|
|
30
29
|
export function dashboardUrl(config, session) {
|
|
31
30
|
return `http://localhost:${config.dashboardPort}${session ? `/o/${session.id}` : "/"}`;
|
|
32
31
|
}
|
|
33
|
-
|
|
32
|
+
// The running server, as it describes itself, or null if none answers.
|
|
33
|
+
export async function serverHealth(config) {
|
|
34
34
|
try {
|
|
35
35
|
const res = await fetch(`http://127.0.0.1:${config.dashboardPort}/health`, { signal: AbortSignal.timeout(1500) });
|
|
36
|
-
return res.ok;
|
|
36
|
+
return res.ok ? (await res.json()) : null;
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
|
-
return
|
|
39
|
+
return null;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
export async function isServerUp(config) {
|
|
43
|
+
return (await serverHealth(config)) !== null;
|
|
44
|
+
}
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { sessionDir } from "./db.js";
|
|
3
|
+
import { modelLabel } from "./models.js";
|
|
3
4
|
const agentName = (s) => (s.agent === "codex" ? "Codex" : "Claude");
|
|
4
5
|
const time = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
5
6
|
// Which model(s) answered, and on what login, from the recorded turns.
|
|
@@ -8,7 +9,7 @@ export function interviewer(store, session) {
|
|
|
8
9
|
const auths = new Set();
|
|
9
10
|
for (const turn of store.turns(session.id)) {
|
|
10
11
|
if (typeof turn.usage?.model === "string")
|
|
11
|
-
models.add(turn.usage.model);
|
|
12
|
+
models.add(modelLabel(turn.usage.model));
|
|
12
13
|
if (typeof turn.usage?.auth === "string")
|
|
13
14
|
auths.add(turn.usage.auth === "none" ? "your subscription" : turn.usage.auth);
|
|
14
15
|
}
|
|
@@ -33,14 +34,17 @@ export function record(store, session) {
|
|
|
33
34
|
function render(store, session, messages, { link, onlyNew, image }) {
|
|
34
35
|
const stats = store.stats(session.id);
|
|
35
36
|
const lines = [];
|
|
36
|
-
lines.push(`# Feedback from ${session.
|
|
37
|
+
lines.push(`# Feedback from ${session.guest} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, `- Files: ${sessionDir(session)}`, ...(session.status === "open" && link ? [`- Guest link: ${link}`] : []), `- Messages: ${stats.guest_messages} from ${session.guest}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
|
|
37
38
|
if (!onlyNew)
|
|
38
39
|
lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
|
|
39
40
|
if (!messages.length)
|
|
40
41
|
lines.push("_Nothing new._");
|
|
41
42
|
for (const m of messages) {
|
|
42
|
-
const who = m.role === "
|
|
43
|
+
const who = m.role === "guest" ? session.guest : m.role === "agent" ? agentName(session) : "Parley (error shown to guest)";
|
|
43
44
|
lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
|
|
45
|
+
const error = m.role === "error" && m.turn_id ? store.turn(m.session_id, m.turn_id)?.error : null;
|
|
46
|
+
if (error)
|
|
47
|
+
lines.push("", `Why it failed (only you see this): ${error}`);
|
|
44
48
|
for (const name of m.images)
|
|
45
49
|
lines.push("", image(name));
|
|
46
50
|
lines.push("");
|
|
@@ -51,11 +55,11 @@ const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
|
|
|
51
55
|
export function summaryLine(store, session, link) {
|
|
52
56
|
const stats = store.stats(session.id);
|
|
53
57
|
const unread = stats.unread ? `, ${stats.unread} new since you last read` : "";
|
|
54
|
-
const heard = stats.
|
|
58
|
+
const heard = stats.last_guest_at ? `last heard from ${time(stats.last_guest_at)}` : "hasn't said anything yet";
|
|
55
59
|
const status = session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}`;
|
|
56
60
|
return [
|
|
57
|
-
`${session.id} ${session.
|
|
58
|
-
` ${stats.
|
|
61
|
+
`${session.id} ${session.guest} on ${session.project_name} [${status}, ${agentName(session)}]`,
|
|
62
|
+
` ${stats.guest_messages} message${stats.guest_messages === 1 ? "" : "s"} from ${session.guest}${unread}; ${heard}`,
|
|
59
63
|
` ${stats.replies} of ${session.reply_limit} replies used${spend(stats.cost_usd)}`,
|
|
60
64
|
...(session.status === "open" ? [` ${link}`] : []),
|
|
61
65
|
].join("\n");
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// This copy of parley's version, from package.json (one folder up from both
|
|
3
|
+
// src/ and dist/).
|
|
4
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
5
|
+
// Negative, zero or positive, like a sort comparator: "0.1.10" is newer than "0.1.9".
|
|
6
|
+
export function compareVersions(a, b) {
|
|
7
|
+
const pa = a.split(".").map(Number);
|
|
8
|
+
const pb = b.split(".").map(Number);
|
|
9
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
10
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
11
|
+
if (d)
|
|
12
|
+
return d;
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parley-im/parley",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Send someone a link. They talk to a read-only Claude or Codex in your project, on your computer, and you get everything they said.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
29
|
+
"node": ">=22.13"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
package/web/app.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* parley: one stylesheet for the
|
|
1
|
+
/* parley: one stylesheet for the guest chat, your session view and your
|
|
2
2
|
control panel. Neutral surfaces, one accent, and a monospace face only where
|
|
3
3
|
it means something (code, paths, the agent's activity, numbers). */
|
|
4
4
|
|
|
@@ -121,6 +121,10 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
121
121
|
.sheet-body p { margin: 0 0 10px; color: var(--muted); }
|
|
122
122
|
.sheet-body p strong { color: var(--text); font-weight: 600; }
|
|
123
123
|
.sheet-body .kv { margin: 6px 0 14px; padding: 10px 12px; background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; }
|
|
124
|
+
.sheet-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; }
|
|
125
|
+
.sheet-body .fine { font-size: 12px; color: var(--faint); }
|
|
126
|
+
.sheet-body details.how { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
127
|
+
.sheet-body details.how summary { cursor: pointer; color: var(--text); font-weight: 500; margin-bottom: 8px; }
|
|
124
128
|
@media (max-width: 560px) {
|
|
125
129
|
.sheet { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: auto; border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom); }
|
|
126
130
|
}
|
|
@@ -135,6 +139,8 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
135
139
|
/* ---------- chat ---------- */
|
|
136
140
|
|
|
137
141
|
.app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
|
|
142
|
+
/* The chat never scrolls the page itself, only its conversation. */
|
|
143
|
+
body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
|
|
138
144
|
.layout { flex: 1; min-height: 0; display: flex; }
|
|
139
145
|
.chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
140
146
|
.chat-inner { width: 100%; max-width: 760px; margin: 0 auto; }
|
|
@@ -158,18 +164,19 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
158
164
|
font-family: var(--mono); font-size: 14px; line-height: 1.55;
|
|
159
165
|
}
|
|
160
166
|
.msg.agent::before { content: "●"; color: var(--accent); font-size: 10px; line-height: 23px; }
|
|
161
|
-
.msg.
|
|
167
|
+
.msg.guest::before { content: "›"; color: var(--muted); font-weight: 600; top: 10px; }
|
|
162
168
|
.msg.error::before { content: "!"; color: var(--danger); font-weight: 700; }
|
|
163
169
|
.meta { font-size: 12px; color: var(--faint); margin-bottom: 2px; }
|
|
164
170
|
.meta .who { color: var(--muted); font-weight: 600; }
|
|
165
|
-
.msg.
|
|
166
|
-
.msg.
|
|
167
|
-
/*
|
|
168
|
-
.
|
|
169
|
-
.
|
|
170
|
-
.
|
|
171
|
-
.msg.
|
|
171
|
+
.msg.guest .bubble { background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; }
|
|
172
|
+
.msg.guest .meta { display: none; }
|
|
173
|
+
/* Guests get just the conversation: no names or times on every message. */
|
|
174
|
+
.guest-view .msg .meta, .guest-view .msg .stamp { display: none; }
|
|
175
|
+
.guest-view .msg { margin-bottom: 16px; }
|
|
176
|
+
.guest-view .msg.guest::before { top: 9px; }
|
|
177
|
+
.msg.guest .stamp { font-size: 12px; color: var(--faint); margin-top: 4px; }
|
|
172
178
|
.msg.error .body { color: var(--danger); font-size: 14px; }
|
|
179
|
+
.msg.error .detail { margin-top: 6px; font-size: 12px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
|
173
180
|
.msg .body { overflow-wrap: anywhere; }
|
|
174
181
|
|
|
175
182
|
.shots { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
@@ -196,7 +203,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
196
203
|
.body li { margin: 0.15em 0; }
|
|
197
204
|
.body h4 { margin: 0.8em 0 0.3em; font-size: 1em; }
|
|
198
205
|
.body code { font-family: var(--mono); font-size: 0.86em; background: var(--subtle); border: 1px solid var(--line); padding: 0.05em 0.35em; border-radius: 5px; }
|
|
199
|
-
.msg.
|
|
206
|
+
.msg.guest .body code { background: var(--panel); }
|
|
200
207
|
.body pre { background: var(--subtle); border: 1px solid var(--line); padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 0.65em; font-size: 13px; }
|
|
201
208
|
.body pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
202
209
|
.body a { color: var(--accent); }
|
|
@@ -218,6 +225,10 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
218
225
|
.dock .chat-inner { padding: 8px 16px calc(8px + env(safe-area-inset-bottom)); }
|
|
219
226
|
.composer { border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow); }
|
|
220
227
|
.composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
228
|
+
/* The sender's computer (or this page's connection) is offline: nothing can
|
|
229
|
+
be sent until it's back, and it looks that way. */
|
|
230
|
+
.app.offline .composer, .app.offline .composer:focus-within { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
|
|
231
|
+
.app.offline .prompt { color: var(--danger); }
|
|
221
232
|
.composer .row { display: flex; align-items: flex-end; gap: 2px; padding: 4px 4px 4px 12px; }
|
|
222
233
|
.prompt { font-family: var(--mono); color: var(--accent); font-weight: 600; padding: 9px 4px 9px 0; line-height: 1.55; }
|
|
223
234
|
textarea {
|
|
@@ -227,10 +238,12 @@ textarea {
|
|
|
227
238
|
field-sizing: content;
|
|
228
239
|
max-height: calc(1.55em * 8 + 16px);
|
|
229
240
|
}
|
|
230
|
-
/* On phones it stops at
|
|
231
|
-
keeps the room. */
|
|
241
|
+
/* On phones it stops at two lines and scrolls inside, and screenshots get
|
|
242
|
+
smaller thumbnails, so the conversation keeps the room. */
|
|
232
243
|
@media (pointer: coarse) {
|
|
233
|
-
textarea { max-height: calc(1.55em *
|
|
244
|
+
textarea { max-height: calc(1.55em * 2 + 16px); }
|
|
245
|
+
.attachments { padding: 8px 8px 0; gap: 6px; }
|
|
246
|
+
.thumb img { width: 40px; height: 40px; border-radius: 6px; }
|
|
234
247
|
}
|
|
235
248
|
textarea::placeholder { color: var(--faint); }
|
|
236
249
|
.icon-btn {
|
|
@@ -380,6 +393,7 @@ body.panel { overflow: auto; }
|
|
|
380
393
|
.site code { font-family: var(--mono); font-size: 0.88em; background: var(--subtle); border: 1px solid var(--line); padding: 0.05em 0.35em; border-radius: 5px; }
|
|
381
394
|
.site h3 { margin: 32px 0 8px; }
|
|
382
395
|
.stack { display: grid; gap: 10px; max-width: 360px; margin-top: 16px; }
|
|
396
|
+
.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); white-space: nowrap; }
|
|
383
397
|
.docs { padding-bottom: 40px; }
|
|
384
398
|
.docs h2 { font-size: 17px; letter-spacing: -0.01em; margin: 36px 0 10px; }
|
|
385
399
|
.docs p, .docs li { max-width: 66ch; }
|
|
@@ -389,6 +403,8 @@ body.panel { overflow: auto; }
|
|
|
389
403
|
.docs pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
390
404
|
.docs blockquote { margin: 10px 0; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; color: var(--text); }
|
|
391
405
|
.trow.site-row { grid-template-columns: 1fr auto; }
|
|
406
|
+
/* Long details shorten instead of pushing the buttons off a phone screen. */
|
|
407
|
+
.trow.site-row .who-cell { min-width: 0; }
|
|
392
408
|
|
|
393
409
|
/* parley.im's front page: no words, just home.js drawing on a faint grid. */
|
|
394
410
|
body.home { overflow: hidden; }
|
package/web/chat.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta property="og:description" content="{{PREVIEW_TEXT}}">
|
|
12
12
|
<meta property="og:site_name" content="parley">
|
|
13
13
|
<title>{{TITLE}}</title>
|
|
14
|
-
<link rel="icon" href="/static/icon.svg">
|
|
14
|
+
<link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
|
|
15
15
|
<link rel="stylesheet" href="/static/app.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
</nav>
|
|
24
24
|
<span class="status" id="status" hidden><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
|
|
25
25
|
<span class="chip" id="chip" hidden></span>
|
|
26
|
-
<button class="icon-btn info-btn" id="info-btn" type="button" title="
|
|
27
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><
|
|
28
|
-
<span class="sr">
|
|
26
|
+
<button class="icon-btn info-btn" id="info-btn" type="button" title="This conversation" hidden>
|
|
27
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14"/><path d="M5 12h14"/><path d="M5 17h9"/></svg>
|
|
28
|
+
<span class="sr">This conversation: details and actions</span>
|
|
29
29
|
</button>
|
|
30
30
|
</header>
|
|
31
31
|
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
</div>
|
|
69
69
|
</div>
|
|
70
70
|
<div class="sheet-back" id="sheet-back" hidden></div>
|
|
71
|
-
<section class="sheet" id="sheet" hidden aria-label="
|
|
72
|
-
<header><h2>
|
|
71
|
+
<section class="sheet" id="sheet" hidden aria-label="This conversation">
|
|
72
|
+
<header><h2>This conversation</h2><button class="btn ghost" id="sheet-close" type="button">Done</button></header>
|
|
73
73
|
<div class="sheet-body" id="sheet-body"></div>
|
|
74
74
|
</section>
|
|
75
75
|
<div class="drop" id="drop" hidden><div>Drop screenshots to add them</div></div>
|