* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
266 lines
11 KiB
JavaScript
266 lines
11 KiB
JavaScript
import { registerPwaManifestRoute } from './pwa-manifest-routes.js';
|
|
|
|
export const createStaticRoutesRuntime = (dependencies) => {
|
|
const {
|
|
fs,
|
|
path,
|
|
process,
|
|
__dirname,
|
|
express,
|
|
resolveProjectDirectory,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
readSettingsFromDiskMigrated,
|
|
normalizePwaAppName,
|
|
normalizePwaOrientation,
|
|
} = dependencies;
|
|
|
|
const resolveDistPath = () => {
|
|
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
|
if (env) {
|
|
return path.resolve(env);
|
|
}
|
|
return path.join(__dirname, '..', 'dist');
|
|
};
|
|
|
|
const registerStaticRoutes = (app) => {
|
|
const distPath = resolveDistPath();
|
|
|
|
if (fs.existsSync(distPath)) {
|
|
console.log(`Serving static files from ${distPath}`);
|
|
app.use(express.static(distPath, {
|
|
setHeaders(res, filePath) {
|
|
// Service workers should never be long-cached; iOS is especially sensitive.
|
|
if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
}
|
|
},
|
|
}));
|
|
|
|
registerPwaManifestRoute(app, {
|
|
process,
|
|
resolveProjectDirectory,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
readSettingsFromDiskMigrated,
|
|
normalizePwaAppName,
|
|
normalizePwaOrientation,
|
|
});
|
|
|
|
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
|
res.sendFile(path.join(distPath, 'index.html'));
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.warn(`Warning: ${distPath} not found, static files will not be served`);
|
|
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
|
res.status(404).send('Static files not found. Please build the application first.');
|
|
});
|
|
};
|
|
|
|
const registerApiOnlyFallbackRoutes = (app) => {
|
|
app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
|
const command = 'openchamber connect-url --help';
|
|
res.status(200).format({
|
|
html: () => {
|
|
res.send(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>OpenChamber API-only mode</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--surface-background: #151313;
|
|
--surface-elevated: #1c1b1a;
|
|
--surface-foreground: #cdccc3;
|
|
--surface-muted-foreground: #b6b4ab;
|
|
--interactive-border: rgba(57,56,54,.72);
|
|
--primary-base: #edb449;
|
|
}
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
color-scheme: light;
|
|
--surface-background: oklch(0.97 0.02 85);
|
|
--surface-elevated: oklch(0.99 0.01 90);
|
|
--surface-foreground: oklch(0.25 0.02 40);
|
|
--surface-muted-foreground: oklch(0.45 0.02 50);
|
|
--interactive-border: rgba(194,151,77,.22);
|
|
--primary-base: oklch(0.65 0.2 55);
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
|
|
background: var(--surface-background);
|
|
color: var(--surface-foreground);
|
|
padding: 32px;
|
|
}
|
|
main {
|
|
width: min(448px, 100%);
|
|
text-align: center;
|
|
}
|
|
.logo {
|
|
width: 86px;
|
|
height: 86px;
|
|
margin: 0 auto 28px;
|
|
display: block;
|
|
color: var(--surface-foreground);
|
|
opacity: .88;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
line-height: 1.2;
|
|
font-weight: 600;
|
|
letter-spacing: -.025em;
|
|
}
|
|
p {
|
|
margin: 10px auto 0;
|
|
max-width: 400px;
|
|
color: var(--surface-muted-foreground);
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
}
|
|
.command {
|
|
margin: 24px auto 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
max-width: 100%;
|
|
padding: 12px 16px;
|
|
border: 1px solid var(--interactive-border);
|
|
border-radius: 10px;
|
|
background: color-mix(in srgb, var(--surface-background) 60%, transparent);
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
code {
|
|
color: var(--surface-foreground);
|
|
font: 13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
white-space: nowrap;
|
|
overflow-x: auto;
|
|
text-align: left;
|
|
}
|
|
button {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--surface-muted-foreground);
|
|
cursor: pointer;
|
|
flex: 0 0 auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2px;
|
|
transition: color .15s ease;
|
|
}
|
|
button:hover { color: var(--surface-foreground); }
|
|
button svg { width: 16px; height: 16px; display: block; }
|
|
.check-icon { display: none; }
|
|
button[data-copied="true"] .copy-icon { display: none; }
|
|
button[data-copied="true"] .check-icon { display: block; color: var(--primary-base); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<svg class="logo" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber logo">
|
|
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
<path d="M8.432 26 L18.824 32 L18.824 44 L8.432 38 Z" fill="currentColor" fill-opacity=".2"/>
|
|
<path d="M18.824 32 L29.216 38 L29.216 50 L18.824 44 Z" fill="currentColor" fill-opacity=".45"/>
|
|
<path d="M29.216 38 L39.608 44 L39.608 56 L29.216 50 Z" fill="currentColor" fill-opacity=".15"/>
|
|
<path d="M39.608 44 L50 50 L50 62 L39.608 56 Z" fill="currentColor" fill-opacity=".55"/>
|
|
<path d="M8.432 38 L18.824 44 L18.824 56 L8.432 50 Z" fill="currentColor" fill-opacity=".35"/>
|
|
<path d="M18.824 44 L29.216 50 L29.216 62 L18.824 56 Z" fill="currentColor" fill-opacity=".1"/>
|
|
<path d="M29.216 50 L39.608 56 L39.608 68 L29.216 62 Z" fill="currentColor" fill-opacity=".5"/>
|
|
<path d="M39.608 56 L50 62 L50 74 L39.608 68 Z" fill="currentColor" fill-opacity=".25"/>
|
|
<path d="M8.432 50 L18.824 56 L18.824 68 L8.432 62 Z" fill="currentColor" fill-opacity=".4"/>
|
|
<path d="M18.824 56 L29.216 62 L29.216 74 L18.824 68 Z" fill="currentColor" fill-opacity=".3"/>
|
|
<path d="M29.216 62 L39.608 68 L39.608 80 L29.216 74 Z" fill="currentColor" fill-opacity=".45"/>
|
|
<path d="M39.608 68 L50 74 L50 86 L39.608 80 Z" fill="currentColor" fill-opacity=".15"/>
|
|
<path d="M8.432 62 L18.824 68 L18.824 80 L8.432 74 Z" fill="currentColor" fill-opacity=".55"/>
|
|
<path d="M18.824 68 L29.216 74 L29.216 86 L18.824 80 Z" fill="currentColor" fill-opacity=".2"/>
|
|
<path d="M29.216 74 L39.608 80 L39.608 92 L29.216 86 Z" fill="currentColor" fill-opacity=".35"/>
|
|
<path d="M39.608 80 L50 86 L50 98 L39.608 92 Z" fill="currentColor" fill-opacity=".1"/>
|
|
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="currentColor" fill-opacity=".15" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="currentColor" fill-opacity=".3"/>
|
|
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="currentColor" fill-opacity=".15"/>
|
|
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="currentColor" fill-opacity=".45"/>
|
|
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="currentColor" fill-opacity=".25"/>
|
|
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="currentColor" fill-opacity=".5"/>
|
|
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="currentColor" fill-opacity=".35"/>
|
|
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="currentColor" fill-opacity=".1"/>
|
|
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="currentColor" fill-opacity=".4"/>
|
|
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="currentColor" fill-opacity=".2"/>
|
|
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="currentColor" fill-opacity=".55"/>
|
|
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="currentColor" fill-opacity=".3"/>
|
|
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="currentColor" fill-opacity=".15"/>
|
|
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="currentColor" fill-opacity=".45"/>
|
|
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="currentColor" fill-opacity=".25"/>
|
|
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="currentColor" fill-opacity=".4"/>
|
|
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="currentColor" fill-opacity=".2"/>
|
|
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
<g transform="matrix(.866 .5 -.866 .5 50 26) scale(.75)">
|
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="currentColor"/>
|
|
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="currentColor" fill-opacity=".4"/>
|
|
</g>
|
|
</svg>
|
|
<h1>OpenChamber is running in headless mode</h1>
|
|
<p>This server is ready. Open it from the OpenChamber desktop or mobile app to use it.</p>
|
|
<div class="command">
|
|
<code id="connect-command">${command}</code>
|
|
<button type="button" id="copy-command" aria-label="Copy command" title="Copy command">
|
|
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M8 7.2C8 6.08 8 5.52 8.218 5.092a2 2 0 0 1 .874-.874C9.52 4 10.08 4 11.2 4h5.6c1.12 0 1.68 0 2.108.218a2 2 0 0 1 .874.874C20 5.52 20 6.08 20 7.2v5.6c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874C18.48 16 17.92 16 16.8 16h-5.6c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C8 14.48 8 13.92 8 12.8V7.2Z" stroke="currentColor" stroke-width="1.8"/>
|
|
<path d="M4 8v8.8C4 17.92 4 18.48 4.218 18.908a2 2 0 0 0 .874.874C5.52 20 6.08 20 7.2 20H16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
</svg>
|
|
<svg class="check-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M5 12.5 9.5 17 19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
const button = document.getElementById('copy-command');
|
|
const command = document.getElementById('connect-command');
|
|
let copyTimer;
|
|
button?.addEventListener('click', async () => {
|
|
const text = command?.textContent || '';
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
button.dataset.copied = 'true';
|
|
window.clearTimeout(copyTimer);
|
|
copyTimer = window.setTimeout(() => {
|
|
button.dataset.copied = 'false';
|
|
}, 1400);
|
|
} catch {
|
|
button.dataset.copied = 'false';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`);
|
|
},
|
|
json: () => {
|
|
res.json({ ok: true, mode: 'api-only', message: 'OpenChamber is running in API-only mode' });
|
|
},
|
|
default: () => {
|
|
res.type('text/plain').send('OpenChamber is running in API-only mode');
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
return {
|
|
registerApiOnlyFallbackRoutes,
|
|
registerStaticRoutes,
|
|
};
|
|
};
|